// Share-link URL schema v1/v2. Positive (selection-based) encoding of the // export filter state so links don't drift when the channel manifest grows. // // Keep this file the *only* home for share-link parsing/building. The legacy // schema lives in `urlState.ts` (the `ch/nov/nol/.../tk` keys on `UrlParams`) // plus the `snapshotToExcluded` helper in `TranscriptSearch.tsx`. Retiring the // legacy schema later means deleting those legacy bits — this file stays. import type { VideoState } from "../lib/availability"; import type { SearchMode } from "./urlState"; export type ShareSelection = { selectedChannels: Set; videos: boolean; livestreams: boolean; allAges: boolean; restricted: boolean; // Availability states to keep. Empty means the link's author deselected // every one — a legitimate (if empty-result) selection. states: ReadonlySet; tracks: Set; // Inclusive upload-date bounds, "YYYYMMDD". Absent => unbounded on that end. dateFrom?: string; dateTo?: string; }; // Legacy filter keys auto-written by older versions of this app. Listed here // so the strip-on-commit helper can clear them alongside v1 keys without // `urlState.ts` having to know about share-link mechanics. export const FILTER_URL_KEYS_LEGACY = [ "ch", "nov", "nol", "naa", "nar", "nav", "nd", "nu", "tk", ] as const; // v1 filter keys. `fv` is the sentinel; the rest are positive selections. export const FILTER_URL_KEYS_V1 = [ "fv", "fc", "ft", "fa", "fav", "fk", "fdf", "fdt", ] as const; // One token per VideoState in `fav`. Single letters because `fav` repeats per // kept state and share links get pasted into chat clients that wrap. const STATE_TOKENS: ReadonlyArray<[VideoState, string]> = [ ["available", "a"], ["maybe_missing", "m"], ["unlisted", "u"], ["private", "p"], ["members_only", "o"], ["deleted", "d"], ]; export const SHARE_VERSION = "2"; // v1 and v2 links are both accepted. The sentinel bump is what makes old links // safe: a v1 link carries at most `fav=a&fav=u&fav=d` and its author meant to // include everything we now call missing, whereas a NEW link that deliberately // drops the missing states is otherwise byte-identical. Without the version we // could not tell "wanted everything, three states existed" from "wanted only // these three". export function hasShareV1(search: string): boolean { const v = new URLSearchParams(search).get("fv"); return v === "1" || v === "2"; } // v1's three tokens mapped onto the six states: `a` also carries maybe_missing // (unconfirmed videos read as available when the link was written), `u` → // unlisted, `d` → deleted plus the two states v1 could not express, since a // private or members-only video showed up as deleted-or-nothing back then and // the author of a v1 link asking for removed videos wanted those too. function statesFromV1(tokens: ReadonlySet): Set { const keep = new Set(); if (tokens.has("a")) { keep.add("available"); keep.add("maybe_missing"); } if (tokens.has("u")) keep.add("unlisted"); if (tokens.has("d")) { keep.add("deleted"); keep.add("private"); keep.add("members_only"); } return keep; } // Channels not listed in `fc` are *not selected*. Unknown channel names in // `fc` (e.g. a renamed/removed channel) are silently dropped against the // current manifest so the resolved set always reflects what actually exists. export function parseShareV1( search: string, allChannelNames: ReadonlyArray, ): ShareSelection { const p = new URLSearchParams(search); const requestedChannels = new Set(p.getAll("fc")); const selectedChannels = new Set(); for (const name of allChannelNames) { if (requestedChannels.has(name)) selectedChannels.add(name); } const types = new Set(p.getAll("ft")); const audience = new Set(p.getAll("fa")); const availability = new Set(p.getAll("fav")); const states = p.get("fv") === "1" ? statesFromV1(availability) : new Set( STATE_TOKENS.filter(([, tok]) => availability.has(tok)).map( ([state]) => state, ), ); const tracks = new Set(p.getAll("fk")); const dfRaw = p.get("fdf"); const dtRaw = p.get("fdt"); return { selectedChannels, videos: types.has("v"), livestreams: types.has("l"), allAges: audience.has("a"), restricted: audience.has("r"), states, tracks, dateFrom: dfRaw && /^\d{8}$/.test(dfRaw) ? dfRaw : undefined, dateTo: dtRaw && /^\d{8}$/.test(dtRaw) ? dtRaw : undefined, }; } export function buildShareSearchParams( committed: ShareSelection, opts: { q: string; mode: SearchMode; regex: boolean }, ): URLSearchParams { const p = new URLSearchParams(); p.set("fv", SHARE_VERSION); const channelNames = Array.from(committed.selectedChannels).sort(); for (const name of channelNames) p.append("fc", name); if (committed.videos) p.append("ft", "v"); if (committed.livestreams) p.append("ft", "l"); if (committed.allAges) p.append("fa", "a"); if (committed.restricted) p.append("fa", "r"); for (const [state, tok] of STATE_TOKENS) { if (committed.states.has(state)) p.append("fav", tok); } for (const tk of Array.from(committed.tracks).sort()) p.append("fk", tk); if (committed.dateFrom) p.set("fdf", committed.dateFrom); if (committed.dateTo) p.set("fdt", committed.dateTo); if (opts.q) p.set("q", opts.q); if (opts.mode === "subs") p.set("m", "subs"); if (opts.regex) p.set("re", "1"); return p; } // Remove every filter-related param (legacy + v1) from the URL via // history.replaceState. Leaves `q`, `m`, `re`, `v`, `t`, `vm` untouched. // Mirrors the popstate-notify dance in `writeUrlParams` so listeners // re-render when keys disappear. export function stripAllFilterParamsFromUrl(): void { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); let changed = false; for (const k of FILTER_URL_KEYS_LEGACY) { if (params.has(k)) { params.delete(k); changed = true; } } for (const k of FILTER_URL_KEYS_V1) { if (params.has(k)) { params.delete(k); changed = true; } } if (!changed) return; const qs = params.toString(); const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`; window.history.replaceState(window.history.state, "", next); window.dispatchEvent(new PopStateEvent("popstate")); }