Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 06a1219a5aa1a5d7eb93e22ce7cee912d4d826ed
parent ca410b8d73d84d0ae275117fa864efe11333b4ac
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 19 May 2026 02:02:52 -0400

New filter url handling

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 0
Acommon/components/shareUrl.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/share-current-search.spec.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 244 insertions(+), 0 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx Binary files differ. diff --git a/common/components/shareUrl.ts b/common/components/shareUrl.ts @@ -0,0 +1,125 @@ +// Share-link URL schema v1. 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 v1 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 { SearchMode } from "./urlState"; + +export type ShareSelection = { + selectedChannels: Set<string>; + videos: boolean; + livestreams: boolean; + allAges: boolean; + restricted: boolean; + available: boolean; + deleted: boolean; + tracks: Set<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", + "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", +] as const; + +export function hasShareV1(search: string): boolean { + return new URLSearchParams(search).get("fv") === "1"; +} + +// 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<string>, +): ShareSelection { + const p = new URLSearchParams(search); + const requestedChannels = new Set(p.getAll("fc")); + const selectedChannels = new Set<string>(); + 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 tracks = new Set(p.getAll("fk")); + return { + selectedChannels, + videos: types.has("v"), + livestreams: types.has("l"), + allAges: audience.has("a"), + restricted: audience.has("r"), + available: availability.has("a"), + deleted: availability.has("d"), + tracks, + }; +} + +export function buildShareSearchParams( + committed: ShareSelection, + opts: { q: string; mode: SearchMode; regex: boolean }, +): URLSearchParams { + const p = new URLSearchParams(); + p.set("fv", "1"); + 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"); + if (committed.available) p.append("fav", "a"); + if (committed.deleted) p.append("fav", "d"); + for (const tk of Array.from(committed.tracks).sort()) p.append("fk", tk); + 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")); +} diff --git a/export/e2e/share-current-search.spec.ts b/export/e2e/share-current-search.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from "@playwright/test"; +import { CHANNEL } from "./fixtures/data"; +import { installRoutes, urlParams } from "./helpers"; + +test.use({ + permissions: ["clipboard-read", "clipboard-write"], +}); + +test.describe("search page — share current search", () => { + test.beforeEach(async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await installRoutes(page); + }); + + test("builds positive-encoded URL and strips filter params on commit", async ({ + page, + }) => { + await page.goto("/"); + // Wait for filter UI to render (gated on manifest load). + const livestreamsBox = page.getByRole("checkbox", { name: "Livestreams" }); + await expect(livestreamsBox).toBeVisible(); + + // Uncheck Livestreams + Deleted so the share link distinguishes selections. + await livestreamsBox.uncheck(); + await page.getByRole("checkbox", { name: "Deleted" }).uncheck(); + + // Type a query and run the search. + const search = page.getByPlaceholder("Search transcripts..."); + await search.fill("alpha"); + await search.press("Enter"); + + // After commit the URL only carries q (filters stripped). + await expect(page).toHaveURL(/\?q=alpha$/); + + // Click Share and inspect the clipboard. + await page.getByRole("button", { name: "Share current search" }).click(); + await expect( + page.getByRole("button", { name: "Link copied!" }), + ).toBeVisible(); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + const url = new URL(clip); + expect(url.searchParams.get("fv")).toBe("1"); + expect(url.searchParams.get("q")).toBe("alpha"); + // The only known channel is still selected (we only edited type/avail). + expect(url.searchParams.getAll("fc")).toEqual([CHANNEL]); + // Videos remains selected, Livestreams was unchecked. + expect(url.searchParams.getAll("ft").sort()).toEqual(["v"]); + // Audience: both selected by default. + expect(url.searchParams.getAll("fa").sort()).toEqual(["a", "r"]); + // Availability: Deleted unchecked → only `a` remains. + expect(url.searchParams.getAll("fav")).toEqual(["a"]); + // Legacy filter params must NOT leak into the new link. + expect(url.searchParams.get("ch")).toBeNull(); + expect(url.searchParams.get("nol")).toBeNull(); + expect(url.searchParams.get("nd")).toBeNull(); + }); + + test("round-trips a v1 share link, then strips filter params on next commit", async ({ + page, + }) => { + // Open a synthesized share link directly. + const params = new URLSearchParams(); + params.set("fv", "1"); + params.append("fc", CHANNEL); + params.append("ft", "v"); + params.append("fa", "a"); + params.append("fa", "r"); + params.append("fav", "a"); + params.set("q", "alpha"); + await page.goto(`/?${params.toString()}`); + + // Wait for filter UI then verify hydration applied the v1 params. + await expect( + page.getByRole("checkbox", { name: "Livestreams" }), + ).not.toBeChecked(); + await expect(page.getByRole("checkbox", { name: "Videos" })).toBeChecked(); + await expect(page.getByRole("checkbox", { name: "Deleted" })).not.toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Available" }), + ).toBeChecked(); + await expect(page.getByPlaceholder("Search transcripts...")).toHaveValue( + "alpha", + ); + + // Trigger another commit to confirm strip-on-commit behavior. + await page.getByPlaceholder("Search transcripts...").press("Enter"); + await expect(page).toHaveURL(/\?q=alpha$/); + const after = await urlParams(page); + expect(after.get("fv")).toBeNull(); + expect(after.get("fc")).toBeNull(); + expect(after.get("ft")).toBeNull(); + expect(after.get("fav")).toBeNull(); + }); + + test("legacy link still applies filters and is stripped on next commit", async ({ + page, + }) => { + // Legacy disables videos via nov=1. After hydration the Videos checkbox + // should be unchecked. + const params = new URLSearchParams(); + params.set("nov", "1"); + params.set("nd", "1"); + params.set("q", "hello"); + await page.goto(`/?${params.toString()}`); + + await expect(page.getByRole("checkbox", { name: "Videos" })).not.toBeChecked(); + await expect(page.getByRole("checkbox", { name: "Deleted" })).not.toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Livestreams" }), + ).toBeChecked(); + + // Re-commit; legacy params are stripped. + await page.getByPlaceholder("Search transcripts...").press("Enter"); + await expect(page).toHaveURL(/\?q=hello$/); + const after = await urlParams(page); + expect(after.get("nov")).toBeNull(); + expect(after.get("nd")).toBeNull(); + }); +});