// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the // address-bar URL for the whole app's navigation. The query string IS the source of truth for the // current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is // bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is // retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. import { TAG_CATEGORIES, type FeedFilters } from "./api"; export const FEED_KEYS = [ "q", "show", "sort", "scope", "source", "normal", "shorts", "live", "tags", "tagmode_language", "tagmode_topic", "tagmode_other", "tagmode", // legacy global (topic-only) — still recognised so old shared links resolve "notag", "channel", "maxage", "from", "to", "year", "mindur", "maxdur", ] as const; const KEYS = FEED_KEYS; // The keys the shell itself reads, whatever page is on screen: the page, the channel page (it opens // over any page), the welcome card's mode and the one-time entry links. No module may declare one of // these (moduleUrl.contract.test), and a page switch never clears them. export const SHELL_KEYS = [ "page", "chan", "m", "link", "oauth", "admin", "account", "login", "deleted", "token", ] as const; export function filtersToParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); if (f.q) p.set("q", f.q); if (f.show && f.show !== "unwatched") p.set("show", f.show); if (f.sort && f.sort !== "newest") p.set("sort", f.sort); if (f.scope === "all") p.set("scope", "all"); // Library provenance filter (only meaningful in "all" scope); "organic" is the default. if (f.scope === "all" && f.librarySource && f.librarySource !== "organic") p.set("source", f.librarySource); if (!f.includeNormal) p.set("normal", "0"); if (f.includeShorts) p.set("shorts", "1"); if (f.includeLive) p.set("live", "1"); if (f.tags.length) p.set("tags", f.tags.join(",")); for (const cat of TAG_CATEGORIES) { if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and"); } if (f.untagged) p.set("notag", "1"); if (f.channelIds.length) p.set("channel", f.channelIds.join(",")); if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays)); if (f.dateFrom) p.set("from", f.dateFrom); if (f.dateTo) p.set("to", f.dateTo); if (f.year) p.set("year", String(f.year)); if (f.minDuration != null) p.set("mindur", String(f.minDuration)); if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration)); return p; } /** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted * value, or null to delete it (so switching a filter back to its default clears the key). Pass `null` * to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them. * Feed the result into urlWithParams(). */ export function filterParamUpdates(f: FeedFilters | null): Record { const p = f ? filtersToParams(f) : new URLSearchParams(); const out: Record = {}; for (const k of KEYS) out[k] = p.get(k); return out; } /** Merge URL params onto a base filter set (typically the defaults). */ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters { const f: FeedFilters = { ...base }; const num = (v: string | null): number | undefined => { if (v == null || v === "") return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; }; if (params.has("q")) f.q = params.get("q") ?? ""; if (params.has("show")) f.show = params.get("show") || "unwatched"; if (params.has("sort")) f.sort = params.get("sort") || "newest"; if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my"; if (params.has("source")) { const s = params.get("source"); f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic"; } if (params.has("normal")) f.includeNormal = params.get("normal") !== "0"; if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1"; if (params.has("live")) f.includeLive = params.get("live") === "1"; if (params.has("tags")) f.tags = (params.get("tags") ?? "") .split(",") .map((s) => Number(s)) .filter((n) => Number.isInteger(n)); // Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot. f.tagModes = { language: params.get("tagmode_language") === "and" ? "and" : "or", topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or", other: params.get("tagmode_other") === "and" ? "and" : "or", }; if (params.has("notag")) f.untagged = params.get("notag") === "1"; f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean); f.maxAgeDays = num(params.get("maxage")); f.dateFrom = params.get("from") || undefined; f.dateTo = params.get("to") || undefined; f.year = num(params.get("year")); f.minDuration = num(params.get("mindur")); f.maxDuration = num(params.get("maxdur")); return f; } /** True if the query string carries any filter param we recognize. */ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } // The single source of truth for valid page ids; `Page` and the runtime validator both // derive from it, so adding a page is a one-line change (no parallel allowlists to keep in // sync). "feed" is the default/fallback. export const PAGES = [ "home", "feed", "channels", "stats", "playlists", "settings", "scheduler", "config", "users", "activity", "notifications", "files", "plex", ] as const; export type Page = (typeof PAGES)[number]; /** Narrow an arbitrary string to a known Page (else null). */ function isPage(p: string | null | undefined): p is Page { return !!p && (PAGES as readonly string[]).includes(p); } // A page this build no longer has, the page that took its place, and the params that open its part // of that page — from the old link's own params. A bookmark, the remembered last page and an old // history entry can still name one; all three resolve through `resolvePage`, so a retired page lands // where its content went instead of on the feed, and the navigation provider writes `params` into the // address bar as it rewrites the page (a null clears a key the old page owned). type Updates = Record; const RETIRED: Readonly< Record Updates }> > = { // The download queue became the tray; what it downloaded lives in Files. downloads: { page: "files" }, // The audit log and the server log became tabs of the activity page (its `at` key). audit: { page: "activity", params: () => ({ at: "admin" }) }, // The server log's own account, text and range became the page's shared filters; a link without // a range meant the log's default then, 24 hours. logs: { page: "activity", params: (old) => ({ at: "logs", au: old.get("lu"), aq: old.get("lq"), aw: old.get("lwin") ?? "24h", lu: null, lq: null, lwin: null, }), }, }; const retired = (p: string | null | undefined) => p && Object.prototype.hasOwnProperty.call(RETIRED, p) ? RETIRED[p]! : null; /** A page id as stored or linked: a live page, a retired page's successor, else null. */ export function resolvePage(p: string | null | undefined): Page | null { if (isPage(p)) return p; return retired(p)?.page ?? null; } /** The params that open a retired page's part of its successor, read from the old link's `params` * (none for a live page). */ export function retiredParams( p: string | null | undefined, params = new URLSearchParams(window.location.search), ): Updates { return isPage(p) ? {} : (retired(p)?.params?.(params) ?? {}); } export function readPage(): Page { return resolvePage(new URLSearchParams(window.location.search).get("page")) ?? "feed"; } /** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query — * a null value deletes that key, any other string sets it. Lets each provider own its own nav key(s) — * page / chan / v / pl / the feed-filter keys — and update the address bar without clobbering the others * (read-modify-write on the live query). The caller decides pushState vs replaceState with the string. * * ⚠️ The FRAGMENT must be carried over. The one-time auth tokens ride it (`#verify=…` / `#reset=…`, SB3), * and the providers that mirror nav params (useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE * the lazily-loaded in main.tsx — their mount effect replaceState's this URL before the auth card's * chunk has even loaded. Dropping the hash here silently ate the token: the verification link opened a * bare sign-in page and the account could never be verified. */ export function urlWithParams(updates: Record): string { const params = new URLSearchParams(window.location.search); for (const [k, v] of Object.entries(updates)) { if (v == null) params.delete(k); else params.set(k, v); } return hrefHere(params); } /** This page's address with its query replaced by `query` and its fragment by `hash` — what every * address-bar writer hands to `history`. Built on the URL itself, never by gluing `pathname` in front: * a path opened as `//evil.com/x` would read as a link to ANOTHER host, `history.replaceState` refuses * that (SecurityError), and the first nav mirror to write threw the whole app into its crash screen. * Relative where that is safe, so the address stays as it was typed; the full href when the path * starts with `//`. */ export function hrefHere(query: URLSearchParams, hash: string = window.location.hash): string { const url = new URL(window.location.href); url.search = query.toString(); url.hash = hash; return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`; } /** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, * ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now * live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now * that the query is the source of truth for navigation. No-op when none of the keys are present. */ export function clearParams(...keys: string[]): void { const params = new URLSearchParams(window.location.search); let changed = false; for (const k of keys) { if (params.has(k)) { params.delete(k); changed = true; } } if (!changed) return; // Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…` // token that has not been consumed yet must survive. Welcome's own cleanup is what clears it. window.history.replaceState(window.history.state, "", hrefHere(params)); }