// MOMENT KEYS — the one name of a cited moment, and the URL of its page. // // span (video, audio) //- page /m///-/ // post / page /m/// // // `source` and `page` citations have no moment: they are shown where they are // cited, never on a page of their own. // // THE KEY IS THE PAGE, so it must be a function of the cited numbers alone: // `start` and `end` are written with TWO DECIMALS, always (`Number#toFixed(2)`, // the same precision as a clip window's name, lib/clipWindow.ts), and a parse // accepts only that spelling — `12.5` is not a key, `12.50` is — so one moment // has exactly one URL. Two citations of the same span share a page; the pad is // not part of the key (it widens the clip, not the moment). Consumers that cut // or show the span use the ROUNDED numbers (`momentOf`), so the page, the clip // file and the key agree. // // SLUG SAFETY: the channel and the id are path segments of a page that a static // build writes to disk, so each must be one safe segment — no `/`, no `\`, not // `.` or `..`, nothing outside `[A-Za-z0-9._-]`. A video id MAY START WITH `-` // (YouTube ids do); that is a safe path segment, but such an id must never be // handed to a command line as a bare argument. `momentKey` throws on an unsafe // segment rather than build a path from it; `momentKeyOf` answers null. // // Pure, no imports but types: the export site's pages and the browser can use it. import type { Citation } from "./schema"; export const MOMENT_SECONDS_DECIMALS = 2; // The route prefix of every moment page. export const MOMENT_ROUTE_PREFIX = "/m/"; // A channel slug: the corpus's own rule (controller/channels.ts // CHANNEL_SLUG_RE, which lib may not import), capped in length. const CHANNEL_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // A record id: like a slug, but it may also start with `-` or `_`. const ID_SEGMENT_RE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/; // A span segment as a key spells it: two decimals each, no sign, no leading // zeros but the one before the point. const SPAN_SEGMENT_RE = /^(0|[1-9]\d*)\.(\d{2})-(0|[1-9]\d*)\.(\d{2})$/; export function isSafeChannelSegment(v: unknown): v is string { return typeof v === "string" && v !== ".." && CHANNEL_SEGMENT_RE.test(v); } export function isSafeIdSegment(v: unknown): v is string { return typeof v === "string" && v !== "." && v !== ".." && ID_SEGMENT_RE.test(v); } export type SpanMoment = { kind: "span"; channel: string; id: string; start: number; end: number }; export type PostMoment = { kind: "post"; channel: string; id: string }; export type Moment = SpanMoment | PostMoment; // Seconds as a key spells them. export function formatMomentSeconds(s: number): string { return s.toFixed(MOMENT_SECONDS_DECIMALS); } // Seconds rounded to the key's precision, as a number. export function roundMomentSeconds(s: number): number { return Number(formatMomentSeconds(s)); } // The moment a citation opens, its span rounded to the key's precision; null // for a kind without a page (source, page). export function momentOf(c: Citation): Moment | null { switch (c.kind) { case "video": case "audio": return { kind: "span", channel: c.channel, id: c.id, start: roundMomentSeconds(c.start), end: roundMomentSeconds(c.end), }; case "post": return { kind: "post", channel: c.channel, id: c.id }; default: return null; } } // Why a moment cannot be keyed, as a sentence, or null when it can. export function momentProblem(m: Moment): string | null { if (!isSafeChannelSegment(m.channel)) return `channel ${JSON.stringify(m.channel)} is not a safe path segment`; if (!isSafeIdSegment(m.id)) return `id ${JSON.stringify(m.id)} is not a safe path segment`; if (m.kind === "span") { if (!Number.isFinite(m.start) || !Number.isFinite(m.end) || m.start < 0) { return "a span's start and end must be numbers, the start at least 0"; } if (!(roundMomentSeconds(m.end) > roundMomentSeconds(m.start))) { return `the span ends (${formatMomentSeconds(m.end)}) at or before it starts (${formatMomentSeconds(m.start)}) at the key's precision`; } } return null; } // The moment's key. Throws on a moment `momentProblem` refuses. export function momentKey(m: Moment): string { const problem = momentProblem(m); if (problem) throw new Error(`moment key: ${problem}`); const base = `${m.channel}/${m.id}`; return m.kind === "span" ? `${base}/${formatMomentSeconds(m.start)}-${formatMomentSeconds(m.end)}` : base; } // The citation's moment key, or null: a kind without a page, or a citation // whose moment cannot be keyed (validation names why). export function momentKeyOf(c: Citation): string | null { const m = momentOf(c); if (!m || momentProblem(m)) return null; return momentKey(m); } // The page of a moment (or of a key): `/m//`. export function momentPath(m: Moment | string): string { return `${MOMENT_ROUTE_PREFIX}${typeof m === "string" ? m : momentKey(m)}/`; } // A key back into its moment, or null for anything that is not a key exactly // as `momentKey` spells it. export function parseMomentKey(key: string): Moment | null { const parts = key.split("/"); if (parts.length === 2) { const [channel, id] = parts; if (!isSafeChannelSegment(channel) || !isSafeIdSegment(id)) return null; return { kind: "post", channel, id }; } if (parts.length === 3) { const [channel, id, spanPart] = parts; if (!isSafeChannelSegment(channel) || !isSafeIdSegment(id)) return null; const m = SPAN_SEGMENT_RE.exec(spanPart); if (!m) return null; const start = Number(`${m[1]}.${m[2]}`); const end = Number(`${m[3]}.${m[4]}`); if (!(end > start)) return null; return { kind: "span", channel, id, start, end }; } return null; } // A moment page's path (`/m//`, the trailing slash optional) back into // its moment, or null. export function parseMomentPath(p: string): Moment | null { if (!p.startsWith(MOMENT_ROUTE_PREFIX)) return null; const key = p.slice(MOMENT_ROUTE_PREFIX.length).replace(/\/$/, ""); return parseMomentKey(key); }