Public: anyone can read this; the crew can change it.

A subtitle file's page shifts every cue by an offset and saves it as a commit #11912

opened this

Check · Review · Land · Live — Its check is queued. Next: reviewer, no review yet.

Ready to land

mason/forge:mason/11862-a-subtitle-file-s-page-shifts-every-cue → main 2 commits fork

Review

Sign in to review this change.
5 files changed +283 −7
src/web/pages/edit.ts +42 −6
@@ -17,7 +17,8 @@
1717 * preview are `editorJs`, and none of them is load-bearing.
1818 */
1919
20 import { fileKind, NO_BYTES } from "../render/kinds.ts";
20 import { fileKind, kindByName, NO_BYTES } from "../render/kinds.ts";
21 import { parseOffset, shiftCues, shiftMessage } from "../render/subtitles.ts";
2122 import { editFiles, MAX_FILE_BYTES } from "../../actions/contents.ts";
2223 import { contribute } from "../../actions/contribute.ts";
2324 import { NO_WRITE_ACCESS } from "../../actions/pull_words.ts";
@@ -843,6 +844,22 @@ export async function contributeSubmit(ctx: Ctx): Promise<Response> {
843844 return runContribute(ctx, scope, scope.propose, form);
844845 }
845846
847 /** A shift that was refused: one sentence why, a 400, and the way back to the file. */
848 function shiftRefused(ctx: Ctx, scope: RepoScope, ref: string, path: string, why: string): Response {
849 return page(
850 {
851 ctx,
852 title: "Not shifted",
853 crumbs: scope.crumbs,
854 palette: scope.palette,
855 body: html`${pageHead("Nothing was shifted")}
856 <p role="alert">${why}</p>
857 <p>${btn("Back to the file", { href: `${scope.base}/blob/${encodeURIComponent(ref)}/${path}`, variant: "primary" })}</p>`,
858 },
859 400,
860 );
861 }
862
846863 /**
847864 * POST /:owner/:name/edit — the one write.
848865 *
@@ -860,17 +877,36 @@ export async function editSubmit(ctx: Ctx): Promise<Response> {
860877
861878 const ref = formValue(form, "ref");
862879 const path = formValue(form, "path").trim().replace(/^\/+/, "");
863 const content = formValue(form, "content");
864 const subject = formValue(form, "message").trim();
880 let content = formValue(form, "content");
881 let subject = formValue(form, "message").trim();
865882 const description = formValue(form, "description").trim();
866 const target = formValue(form, "target") === "new" ? "new" : "current";
867 const branch = formValue(form, "branch").trim();
868 const pull = formValue(form, "pull") !== "" && target === "new";
869883 const pullTitle = formValue(form, "pullTitle").trim();
870884 const pullBody = formValue(form, "pullBody").trim();
871885 const creating = formValue(form, "creating") === "1";
872886
873887 const mustBranch = await mustBranchFrom(ctx, scope, ref);
888 // A subtitle shift: the server reads the file and writes the moved text,
889 // then the same checks and the same write below apply.
890 const shiftText = form.has("shift") ? formValue(form, "shift") : undefined;
891 let target: "new" | "current" = formValue(form, "target") === "new" ? "new" : "current";
892 let branch = formValue(form, "branch").trim();
893 let pull = formValue(form, "pull") !== "" && target === "new";
894 if (shiftText !== undefined) {
895 if (!isSafeRef(ref)) return notFoundPage(ctx);
896 if (path === "" || !isSafeTreePath(path) || kindByName(path) !== "subtitles")
897 return shiftRefused(ctx, scope, ref, path, "Only a subtitle file (.srt, .vtt) can be shifted.");
898 const offset = parseOffset(shiftText);
899 if (!offset.ok) return shiftRefused(ctx, scope, ref, path, offset.why);
900 const blob = await scope.source.blob(ref, path);
901 if (!blob.ok || blob.value.kind !== "text") return notFoundPage(ctx);
902 content = shiftCues(new TextDecoder().decode(blob.value.body), offset.ms).text;
903 subject = shiftMessage(offset.ms);
904 if (mustBranch) {
905 target = "new";
906 branch = suggestBranch(ctx.auth.user.username, path);
907 pull = true;
908 }
909 }
874910 const draft: Draft = {
875911 ref,
876912 path,
src/web/pages/repo.ts +1
@@ -1211,6 +1211,7 @@ export function renderBlob(
12111211 all: o.all === true,
12121212 ...(o.table !== undefined ? { table: o.table } : {}),
12131213 ...(o.layer !== undefined ? { layer: o.layer } : {}),
1214 ...(scope.caps.write && scope.kind === "local" ? { shift: { action: `${base}/edit`, ref: o.ref, path: o.path } } : {}),
12141215 })
12151216 : undefined;
12161217 const fellBack = drawn?.fallback;
src/web/render/renderers.ts +2
@@ -67,6 +67,8 @@ export interface RenderInput {
6767 readonly all?: boolean;
6868 /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */
6969 readonly table?: string;
70 /** Set for a writer only: where a shift of this file posts (the file edit route), and what it names. */
71 readonly shift?: { readonly action: string; readonly ref: string; readonly path: string };
7072 /** `?layer=N` (1-based) on a G-code file; the renderer clamps it to the layers it has. */
7173 readonly layer?: number;
7274 }
src/web/render/subtitles.ts +81 −1
@@ -77,6 +77,86 @@ export function parseSubtitles(source: string): Transcript {
7777 return { cues, failures };
7878 }
7979
80 /** The most a shift may move a file, either way, in seconds. */
81 export const MAX_SHIFT_SECONDS = 3600;
82
83 /** An offset in seconds as typed: a signed decimal, or the reason it was refused. */
84 export function parseOffset(text: string): { ok: true; ms: number } | { ok: false; why: string } {
85 const t = text.trim();
86 if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(t))
87 return { ok: false, why: "Give the offset as a number of seconds, like 2.5 or -1." };
88 const seconds = Number(t);
89 if (!Number.isFinite(seconds) || Math.abs(seconds) > MAX_SHIFT_SECONDS)
90 return { ok: false, why: `The offset can be at most ${MAX_SHIFT_SECONDS} seconds either way.` };
91 return { ok: true, ms: Math.round(seconds * 1000) };
92 }
93
94 /** The commit message a shift is saved under: "Shift subtitles by +2.5 s". */
95 export function shiftMessage(ms: number): string {
96 const seconds = ms / 1000;
97 return `Shift subtitles by ${seconds >= 0 ? "+" : "-"}${Math.abs(seconds)} s`;
98 }
99
100 const STAMP = /^(?:(\d+):)?(\d{1,2}):(\d{2})([.,])(\d{1,3})$/;
101 const SHIFT_LINE = new RegExp(String.raw`^(\s*)(${TIME})(\s*-->\s*)(${TIME})(.*)$`);
102
103 function shiftStamp(stamp: string, ms: number): string {
104 const m = STAMP.exec(stamp)!;
105 const total =
106 ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1000 +
107 Number(m[5]!.padEnd(3, "0")) +
108 ms;
109 const t = Math.max(0, total);
110 const h = Math.floor(t / 3_600_000);
111 const two = (n: number): string => String(n).padStart(2, "0");
112 const rest = `${two(Math.floor(t / 60_000) % 60)}:${two(Math.floor(t / 1000) % 60)}${m[4]}${String(t % 1000).padStart(3, "0")}`;
113 return m[1] !== undefined || h > 0 ? `${two(h)}:${rest}` : rest;
114 }
115
116 /**
117 * Move every cue's start and end by `ms` (signed), never below zero. Only the
118 * two timestamps of a timing line change; numbering, settings, text and line
119 * endings are kept byte for byte. Pure; never throws.
120 */
121 export function shiftCues(text: string, ms: number): { text: string; cues: number } {
122 const lines = text.split("\n");
123 const vtt = /^(?:)?WEBVTT(?:[ \t]|$)/.test(lines[0] ?? "");
124 let cues = 0;
125 let inBlock = false;
126 let timed = false;
127 let skip = false;
128 const out = lines.map((line, i) => {
129 if (line.trim() === "") {
130 inBlock = false;
131 return line;
132 }
133 if (!inBlock) {
134 inBlock = true;
135 timed = false;
136 skip = vtt && (i === 0 || /^(NOTE|STYLE|REGION)(?:[ \t]|$)/.test(line));
137 }
138 if (skip || timed || !line.includes("-->")) return line;
139 timed = true;
140 const cr = line.endsWith("\r") ? "\r" : "";
141 const m = SHIFT_LINE.exec(cr === "" ? line : line.slice(0, -1));
142 if (m === null) return line;
143 cues++;
144 return `${m[1]}${shiftStamp(m[2]!, ms)}${m[3]}${shiftStamp(m[4]!, ms)}${m[5]}${cr}`;
145 });
146 return { text: out.join("\n"), cues };
147 }
148
149 /** The one small form on a writer's subtitle page; it posts to the file edit route. */
150 function shiftForm(shift: NonNullable<RenderInput["shift"]>): string {
151 return `<form method="post" action="${escapeHtml(shift.action)}" class="edit-row mt">
152 <input type="hidden" name="ref" value="${escapeHtml(shift.ref)}">
153 <input type="hidden" name="path" value="${escapeHtml(shift.path)}">
154 <label>Shift every cue by <input class="input" name="shift" type="text" inputmode="decimal" value="0" size="6" required> seconds</label>
155 <button class="btn" data-size="sm" type="submit">Shift and save</button>
156 <span class="muted small">Signed, up to ${MAX_SHIFT_SECONDS} either way. Saved as a commit.</span>
157 </form>`;
158 }
159
80160 export function subtitles(input: RenderInput): Rendered {
81161 const { cues, failures } = parseSubtitles(new TextDecoder().decode(input.body));
82162 if (cues.length === 0 && failures.length === 0)
@@ -100,7 +180,7 @@ export function subtitles(input: RenderInput): Rendered {
100180 const content = html`<table class="table subs-view" data-dense aria-label="${name} transcript">
101181 <thead><tr><th scope="col">Start</th><th scope="col">Text</th></tr></thead>
102182 <tbody>${raw(rows)}</tbody>
103 </table>${raw(notes.map((n) => `<p class="muted small">${n}</p>`).join(""))}`;
183 </table>${raw(notes.map((n) => `<p class="muted small">${n}</p>`).join(""))}${raw(input.shift === undefined ? "" : shiftForm(input.shift))}`;
104184 return {
105185 content,
106186 lines: 0,
test/render-subtitles-shift.test.ts added +157
@@ -0,0 +1,157 @@
1 /** Shifting every cue of a subtitle file: the pure function, and the page that saves it as a commit. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { signUp } from "../src/actions/users.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { getRepo } from "../src/core/repos.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { commitLog } from "../src/git/read.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { localSource } from "../src/view/local.ts";
15 import { parseOffset, shiftCues, shiftMessage } from "../src/web/render/subtitles.ts";
16 import { fixtureSlot } from "./helpers/slot.ts";
17 import { seedAdmin } from "./helpers/admin.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const SRT = "1\r\n00:00:01,500 --> 00:00:03,000\r\nHello <i>there</i>\r\n\r\n2\r\n01:02:03,004 --> 01:02:05,000\r\nLine one\r\n";
23 const VTT = "WEBVTT - demo\n\nNOTE a --> b\n\nintro\n00:01.250 --> 00:03.000 align:start\n<v Ann>Hi</v>\n\n00:01:00.000 --> 00:01:02.000\nBye\n";
24
25 describe("shiftCues", () => {
26 test("+2.5 s on SRT moves start and end and keeps everything else", () => {
27 const r = shiftCues(SRT, 2500);
28 expect(r.cues).toBe(2);
29 expect(r.text).toBe(SRT.replace("00:00:01,500 --> 00:00:03,000", "00:00:04,000 --> 00:00:05,500").replace("01:02:03,004 --> 01:02:05,000", "01:02:05,504 --> 01:02:07,500"));
30 });
31 test("-1 s on SRT", () => {
32 expect(shiftCues(SRT, -1000).text).toContain("00:00:00,500 --> 00:00:02,000");
33 });
34 test("+2.5 s on WebVTT keeps the header, note, id, settings and hourless stamps", () => {
35 const r = shiftCues(VTT, 2500);
36 expect(r.cues).toBe(2);
37 expect(r.text).toBe(VTT.replace("00:01.250 --> 00:03.000", "00:03.750 --> 00:05.500").replace("00:01:00.000 --> 00:01:02.000", "00:01:02.500 --> 00:01:04.500"));
38 });
39 test("-1 s on WebVTT", () => {
40 expect(shiftCues(VTT, -1000).text).toContain("00:00.250 --> 00:02.000");
41 });
42 test("an hourless stamp grows an hour when it must", () => {
43 expect(shiftCues("WEBVTT\n\n59:59.000 --> 59:59.500\nx\n", 2000).text).toContain("01:00:01.000 --> 01:00:01.500");
44 });
45 test("never below zero", () => {
46 const r = shiftCues(SRT, -10_000);
47 expect(r.text).toContain("00:00:00,000 --> 00:00:00,000");
48 expect(r.text).toContain("01:01:53,004 --> 01:01:55,000");
49 });
50 test("a file with no cues is returned unchanged", () => {
51 expect(shiftCues("nothing\n", 1000)).toEqual({ text: "nothing\n", cues: 0 });
52 });
53 });
54
55 describe("parseOffset", () => {
56 test("a signed decimal in seconds", () => {
57 expect(parseOffset("+2.5")).toEqual({ ok: true, ms: 2500 });
58 expect(parseOffset(" -1 ")).toEqual({ ok: true, ms: -1000 });
59 expect(parseOffset("3600")).toEqual({ ok: true, ms: 3_600_000 });
60 });
61 test("malformed or out of range is refused with a reason", () => {
62 for (const bad of ["", "abc", "1e3", "NaN", "Infinity", "1,5", "3600.001", "-3601", "--1"]) {
63 const r = parseOffset(bad);
64 expect(r.ok).toBe(false);
65 if (!r.ok) expect(r.why.length).toBeGreaterThan(10);
66 }
67 });
68 test("the commit message", () => {
69 expect(shiftMessage(2500)).toBe("Shift subtitles by +2.5 s");
70 expect(shiftMessage(-1000)).toBe("Shift subtitles by -1 s");
71 });
72 });
73
74 describe("the subtitle page's shift form", () => {
75 let dataDir: string;
76 let app: App;
77 let servers: Servers;
78 let base = "";
79 let origin = "";
80 let alice = "";
81 let kim = "";
82
83 const post = (fields: Record<string, string>, cookie?: string, from = origin) =>
84 fetch(`${base}/alice/subs/edit`, {
85 method: "POST",
86 redirect: "manual",
87 headers: { origin: from, ...(cookie === undefined ? {} : { cookie }) },
88 body: new URLSearchParams(fields),
89 });
90 const file = async (): Promise<string> => {
91 const repo = getRepo(app.db, "alice", "subs")!;
92 const blob = await localSource(app, repo, undefined).blob("main", "a.srt");
93 if (!blob.ok || blob.value.kind !== "text") throw new Error("no file");
94 return new TextDecoder().decode(blob.value.body);
95 };
96 const page = async (cookie?: string) =>
97 await (await fetch(`${base}/alice/subs/blob/main/a.srt`, { headers: cookie === undefined ? {} : { cookie } })).text();
98
99 beforeAll(async () => {
100 dataDir = await mkdtemp(join(tmpdir(), "forge-shift-"));
101 app = createApp({ dataDir, port: 0 });
102 servers = await serve(app);
103 base = servers.url;
104 origin = new URL(base).origin;
105 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-shift-passphrase" });
106 const k = await signUp(app, { username: "kim", email: "[email protected]", password: "a-long-shift-passphrase" }, { slot: fixtureSlot(app) });
107 if (!a.ok || !k.ok) throw new Error("signup failed");
108 alice = `forge_session=${createSession(app.db, a.value.id).token}`;
109 kim = `forge_session=${createSession(app.db, k.value.id).token}`;
110 const made = await createRepo(app, a.value, { name: "subs", visibility: "public" });
111 if (!made.ok) throw new Error("repo failed");
112 await commitToBranch(app, a.value, made.value, { message: "first", files: [{ path: "a.srt", content: SRT }] });
113 });
114 afterAll(async () => {
115 servers.stop();
116 app.close();
117 await rm(dataDir, { recursive: true, force: true });
118 });
119
120 test("a writer sees the form, a reader does not", async () => {
121 expect(await page(alice)).toContain('name="shift"');
122 expect(await page(kim)).not.toContain('name="shift"');
123 expect(await page()).not.toContain('name="shift"');
124 });
125
126 test("a malformed offset is refused with a reason and writes nothing", async () => {
127 const res = await post({ ref: "main", path: "a.srt", shift: "soon" }, alice);
128 expect(res.status).toBe(400);
129 expect(await res.text()).toContain("number of seconds");
130 expect(await file()).toBe(SRT);
131 });
132
133 test("a non-writer is refused and writes nothing", async () => {
134 const res = await post({ ref: "main", path: "a.srt", shift: "2.5" }, kim);
135 expect(res.status).toBe(404);
136 expect(await file()).toBe(SRT);
137 });
138
139 test("a cross-origin POST is refused", async () => {
140 const res = await post({ ref: "main", path: "a.srt", shift: "2.5" }, alice, "https://evil.example");
141 expect(res.status).toBe(403);
142 expect(await file()).toBe(SRT);
143 });
144
145 test("a non-subtitle path is refused", async () => {
146 const res = await post({ ref: "main", path: "README.md", shift: "2.5" }, alice);
147 expect(res.status).toBe(400);
148 });
149
150 test("a writer's shift is a commit with a plain message", async () => {
151 const res = await post({ ref: "main", path: "a.srt", shift: "2.5" }, alice);
152 expect(res.status).toBe(303);
153 expect(await file()).toBe(shiftCues(SRT, 2500).text);
154 const log = await commitLog(repoDir(app.reposRoot, "alice", "subs"), "main", { limit: 1 });
155 expect(log.ok && log.value[0]?.subject).toBe("Shift subtitles by +2.5 s");
156 });
157 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs