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
Check · Review · Land · Live — Its check is queued.
mason/forge:mason/11862-a-subtitle-file-s-page-shifts-every-cue → main 2 commits fork
5 files
changed +283
−7
src/web/pages/edit.ts +42 −6
| @@ -17,7 +17,8 @@ | ||
| 17 | 17 | * preview are `editorJs`, and none of them is load-bearing. |
| 18 | 18 | */ |
| 19 | 19 | |
| 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"; | |
| 21 | 22 | import { editFiles, MAX_FILE_BYTES } from "../../actions/contents.ts"; |
| 22 | 23 | import { contribute } from "../../actions/contribute.ts"; |
| 23 | 24 | import { NO_WRITE_ACCESS } from "../../actions/pull_words.ts"; |
| @@ -843,6 +844,22 @@ export async function contributeSubmit(ctx: Ctx): Promise<Response> { | ||
| 843 | 844 | return runContribute(ctx, scope, scope.propose, form); |
| 844 | 845 | } |
| 845 | 846 | |
| 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 | ||
| 846 | 863 | /** |
| 847 | 864 | * POST /:owner/:name/edit — the one write. |
| 848 | 865 | * |
| @@ -860,17 +877,36 @@ export async function editSubmit(ctx: Ctx): Promise<Response> { | ||
| 860 | 877 | |
| 861 | 878 | const ref = formValue(form, "ref"); |
| 862 | 879 | 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(); | |
| 865 | 882 | 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"; | |
| 869 | 883 | const pullTitle = formValue(form, "pullTitle").trim(); |
| 870 | 884 | const pullBody = formValue(form, "pullBody").trim(); |
| 871 | 885 | const creating = formValue(form, "creating") === "1"; |
| 872 | 886 | |
| 873 | 887 | 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 | } | |
| 874 | 910 | const draft: Draft = { |
| 875 | 911 | ref, |
| 876 | 912 | path, |
src/web/pages/repo.ts +1
| @@ -1211,6 +1211,7 @@ export function renderBlob( | ||
| 1211 | 1211 | all: o.all === true, |
| 1212 | 1212 | ...(o.table !== undefined ? { table: o.table } : {}), |
| 1213 | 1213 | ...(o.layer !== undefined ? { layer: o.layer } : {}), |
| 1214 | ...(scope.caps.write && scope.kind === "local" ? { shift: { action: `${base}/edit`, ref: o.ref, path: o.path } } : {}), | |
| 1214 | 1215 | }) |
| 1215 | 1216 | : undefined; |
| 1216 | 1217 | const fellBack = drawn?.fallback; |
src/web/render/renderers.ts +2
| @@ -67,6 +67,8 @@ export interface RenderInput { | ||
| 67 | 67 | readonly all?: boolean; |
| 68 | 68 | /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */ |
| 69 | 69 | 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 }; | |
| 70 | 72 | /** `?layer=N` (1-based) on a G-code file; the renderer clamps it to the layers it has. */ |
| 71 | 73 | readonly layer?: number; |
| 72 | 74 | } |
src/web/render/subtitles.ts +81 −1
| @@ -77,6 +77,86 @@ export function parseSubtitles(source: string): Transcript { | ||
| 77 | 77 | return { cues, failures }; |
| 78 | 78 | } |
| 79 | 79 | |
| 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 | ||
| 80 | 160 | export function subtitles(input: RenderInput): Rendered { |
| 81 | 161 | const { cues, failures } = parseSubtitles(new TextDecoder().decode(input.body)); |
| 82 | 162 | if (cues.length === 0 && failures.length === 0) |
| @@ -100,7 +180,7 @@ export function subtitles(input: RenderInput): Rendered { | ||
| 100 | 180 | const content = html`<table class="table subs-view" data-dense aria-label="${name} transcript"> |
| 101 | 181 | <thead><tr><th scope="col">Start</th><th scope="col">Text</th></tr></thead> |
| 102 | 182 | <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))}`; | |
| 104 | 184 | return { |
| 105 | 185 | content, |
| 106 | 186 | 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 | }); |
new above
offline