Archilyzer · Source

archilyzer

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

commit bd8ef15aa5f683f9a27f26b6b754e503ed7a6755
parent d565ab7685388ad8c9a958c94526cc33175b8f4d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 22 May 2026 16:16:26 -0400

Rework export search profiles

Diffstat:
Mcommon/components/QueryLeafView.tsx | 9---------
Mcommon/components/TranscriptSearch.tsx | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mexport/CHANGELOG.md | 5+++++
Aexport/e2e/profile-row.spec.ts | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/share-current-search.spec.ts | 9+++++++--
5 files changed, 387 insertions(+), 38 deletions(-)

diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx @@ -30,12 +30,6 @@ const SCOPE_LABELS: Record<LayerScope, string> = { metadata: "Title / channel", }; -const SCOPE_COST: Record<LayerScope, string> = { - transcripts: "slow", - chat: "medium", - metadata: "fast", -}; - export default function QueryLeafView({ leaf, state, @@ -96,9 +90,6 @@ export default function QueryLeafView({ </option> ))} </select> - <span className="text-[10px] uppercase tracking-wide text-zinc-400"> - {SCOPE_COST[leaf.scope]} - </span> </label> ); diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -78,6 +78,12 @@ const DEFAULT_FLUSH_INTERVAL_MS = 120; // the card's actual height and the virtualizer re-positions later cards. const CARD_ROW_ESTIMATE_PX = 120; +// Canonical hash of a freshly-constructed empty root — used to disable +// "Reset layers" when the draft is already at the empty default. Module-level +// so we don't reallocate a node every render; `canonicalHash` strips IDs so +// the value is stable. +const EMPTY_ROOT_HASH = canonicalHash(emptyRoot()); + // Legacy URL keys still parsed for hydration (old share-links), but stripped // on the first commit so the modern `qt=` is canonical going forward. const FILTER_URL_KEYS = [ @@ -988,6 +994,24 @@ export default function TranscriptSearch() { applyDraftSnapshot(null); }, [applyDraftSnapshot, writeStorage]); + const handleResetLayers = useCallback(() => { + setDraftRoot(emptyRoot()); + }, []); + + const handleResetEverything = useCallback(() => { + handleResetLayers(); + handleResetAllFilters(); + }, [handleResetLayers, handleResetAllFilters]); + + const handleRevertProfile = useCallback(() => { + if (!activeProfileName) return; + const snap = profiles[activeProfileName]; + if (!snap) return; + applySnapshot(snap); + }, [activeProfileName, profiles, applySnapshot]); + + const layersResetDisabled = draftHash === EMPTY_ROOT_HASH; + const [shareCopied, setShareCopied] = useState(false); const shareResetRef = useRef<number | null>(null); const handleShareCurrentSearch = useCallback(async () => { @@ -1093,6 +1117,19 @@ export default function TranscriptSearch() { return ( <div className="flex flex-col gap-6"> + <ProfilesRow + profileNames={Object.keys(profiles).sort()} + activeProfileName={activeProfileName} + profileDirty={profileDirty} + shareCopied={shareCopied} + onSelect={handleLoadProfile} + onSave={handleSaveProfile} + onSaveAs={handleSaveAsProfile} + onRename={handleRenameProfile} + onDelete={handleDeleteProfile} + onRevert={handleRevertProfile} + onShareCurrentSearch={handleShareCurrentSearch} + /> <form onSubmit={(e) => { e.preventDefault(); @@ -1123,6 +1160,15 @@ export default function TranscriptSearch() { > Search </button> + <button + type="button" + onClick={handleResetLayers} + disabled={layersResetDisabled} + data-testid="reset-layers" + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-40 disabled:no-underline disabled:cursor-not-allowed" + > + Reset layers + </button> {(queryDirty || filtersDirty) && ( <span className="text-xs text-amber-600 dark:text-amber-400"> Press Enter or click Search to apply @@ -1144,16 +1190,6 @@ export default function TranscriptSearch() { <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400 -mt-3"> {channelOptions.length > 1 && ( <div className="flex flex-col gap-2 w-full"> - <ProfilesRow - profileNames={Object.keys(profiles).sort()} - activeProfileName={activeProfileName} - profileDirty={profileDirty} - onSelect={handleLoadProfile} - onSave={handleSaveProfile} - onSaveAs={handleSaveAsProfile} - onRename={handleRenameProfile} - onDelete={handleDeleteProfile} - /> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="text-xs uppercase tracking-wide text-zinc-500"> Channels @@ -1177,6 +1213,14 @@ export default function TranscriptSearch() { > Reset all filters </button> + <button + type="button" + onClick={handleResetEverything} + data-testid="reset-everything" + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + Reset everything + </button> </div> <div className="flex flex-col gap-1.5"> {channelGroupings.map(({ group, channels }) => { @@ -1337,20 +1381,6 @@ export default function TranscriptSearch() { Deleted </label> </div> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1 w-full"> - <button - type="button" - onClick={handleShareCurrentSearch} - title={ - shareCopied - ? "Copied!" - : "Copy link encoding the current search + filters" - } - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - > - {shareCopied ? "Link copied!" : "Share current search"} - </button> - </div> </div> )} @@ -1729,29 +1759,40 @@ function ProfilesRow({ profileNames, activeProfileName, profileDirty, + shareCopied, onSelect, onSave, onSaveAs, onRename, onDelete, + onRevert, + onShareCurrentSearch, }: { profileNames: string[]; activeProfileName: string | null; profileDirty: boolean; + shareCopied: boolean; onSelect: (name: string) => void; onSave: () => void; onSaveAs: () => void; onRename: () => void; onDelete: () => void; + onRevert: () => void; + onShareCurrentSearch: () => void; }) { + const showRevert = activeProfileName != null && profileDirty; return ( - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <div + className="flex flex-wrap items-center gap-x-3 gap-y-1" + data-testid="profiles-row" + > <span className="text-xs uppercase tracking-wide text-zinc-500"> Profile </span> <select value={activeProfileName ?? ""} onChange={(e) => onSelect(e.target.value)} + data-testid="profile-select" className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-0.5 text-sm" > <option value="">(unsaved)</option> @@ -1764,10 +1805,11 @@ function ProfilesRow({ {profileDirty && ( <span role="status" - className="text-xs text-amber-700 dark:text-amber-300" aria-label="profile has unsaved changes" + data-testid="profile-dirty-dot" + className="text-base leading-none text-amber-700 dark:text-amber-300" > - (unsaved) + • </span> )} <button @@ -1785,6 +1827,16 @@ function ProfilesRow({ > Save as… </button> + {showRevert && ( + <button + type="button" + onClick={onRevert} + data-testid="profile-revert" + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + Revert + </button> + )} <button type="button" onClick={onRename} @@ -1801,6 +1853,18 @@ function ProfilesRow({ > Delete </button> + <button + type="button" + onClick={onShareCurrentSearch} + title={ + shareCopied + ? "Copied!" + : "Copy link encoding the current search + filters" + } + className="ml-auto text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + {shareCopied ? "Link copied!" : "Share current search"} + </button> </div> ); } diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -3,9 +3,14 @@ ## [Unreleased] ### Changed +- Reorganised the search/filter UI to put the profile selector at the top, framing both the query builder and the filters. The "Profile" row is no longer gated on having more than one channel — profiles cover the layered query *and* the filters, so the selector is meaningful even with a single channel. The "Share current search" button moved into that same row (it's the URL-shareable counterpart of a locally-saved profile). The dirty-profile indicator switched from the literal text "(unsaved)" to a small amber `•` — "(unsaved)" was doing double duty as both the placeholder option label and the dirty marker, which made the wording ambiguous. +- Removed the "slow / medium / fast" cost badges next to each layer's scope dropdown. They read as a warning on the primary mode (transcripts) without giving users any actionable choice. - Search results now render through a virtualised list (`@tanstack/react-virtual`), one element per matching video. Cards mount in full when they enter the viewport (header + leaf sections + hits laid out in normal CSS flow inside the card) and unmount once scrolled away — earlier iterations virtualised every row inside each card, which produced occasional empty bands between a card's header and its leaf sections while ResizeObserver caught up to row sizes. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous virtualised list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabelled now that pagination of cards is no longer needed). ### Added +- "Reset layers" button next to the Search submit collapses the query builder back to a single empty transcripts leaf in one click. Disabled when the draft is already at that empty state. +- "Revert" button surfaces in the Profile row whenever an active profile is dirty — re-applies the saved snapshot (filters and query tree both) so users can experiment with edits and roll them back without re-selecting the profile. +- "Reset everything" button in the filter reset row clears the layered query *and* every filter (channels, type, audience, availability) and drops the active profile pointer in one shot. The narrower "Reset channels" / "Reset all filters" buttons stay alongside it. - Composable layered search. The single search bar is now a query builder: any number of layers can be combined with AND / OR / NOT and arbitrary nesting. Each layer targets a scope (transcripts, live chat, or title/channel), and matches from every contributing layer are surfaced in the result list with a per-layer colour swatch. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. The composite query serialises into a new `qt=` URL parameter; legacy `?q=&m=&re=` links auto-migrate to a one-layer tree. - "Unwrap" button on every non-root group in the query builder. Flattens the group's children into the parent at the group's position, giving users a fast way back out of a sub-group they no longer want. If the group has NOT set, each promoted child inherits a flipped `negate` so the meaning of the subtree doesn't silently flip. - New `/changelog` page that renders the export's `CHANGELOG.md`. Linked from the right side of the sticky header. diff --git a/export/e2e/profile-row.spec.ts b/export/e2e/profile-row.spec.ts @@ -0,0 +1,284 @@ +import { expect, test, type Page } from "@playwright/test"; +import { CHANNEL } from "./fixtures/data"; +import { installRoutes } from "./helpers"; + +const STORAGE_KEY = "ytdlp-tb:export-filters"; + +// Smoke-test the post-refactor search UI: +// - "slow / medium / fast" cost badges are gone from each layer's scope. +// - "Reset layers" sits next to Search and reverts the builder to one empty +// transcripts leaf. +// - The Profile row renders *above* the QueryBuilder, regardless of the +// channel count. +// - Share button lives inside the Profile row. +// - The dirty-marker uses a `•` glyph rather than the word "(unsaved)". +// - A "Revert" button shows up only when the active profile is dirty, and +// restores the saved snapshot. +// - "Reset everything" wipes both layers and filters (covered with a +// multi-channel route override so the gated reset row renders). + +test.describe("search UI — profile row + reset refinements", () => { + test.beforeEach(async ({ page }) => { + await installRoutes(page); + }); + + test("scope dropdown no longer renders slow/medium/fast cost badges", async ({ + page, + }) => { + await page.goto("/"); + // Wait for the builder to render (the scope <select> is its + // first non-input control). + const scope = page.locator('[data-testid^="leaf-scope-"]').first(); + await expect(scope).toBeVisible(); + // The badge used to sit as a sibling <span> right after the <select>. + // After the change, no such literal text should be in the DOM. + await expect(page.getByText(/^slow$/i)).toHaveCount(0); + await expect(page.getByText(/^medium$/i)).toHaveCount(0); + await expect(page.getByText(/^fast$/i)).toHaveCount(0); + }); + + test("Reset layers reverts a multi-layer tree to one empty leaf", async ({ + page, + }) => { + // Seed a 2-leaf tree via the canonical `?qt=` param. + const tree = { + k: "g", + o: "AND", + c: [ + { k: "l", q: "alpha", s: "transcripts" }, + { k: "l", q: "beta", s: "transcripts" }, + ], + }; + await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(tree))}`); + + // The builder should be in full (non-compact) mode with 2 leaves. + const leafInputs = page.locator('[data-testid^="leaf-query-"]'); + await expect(leafInputs).toHaveCount(2); + + const reset = page.getByTestId("reset-layers"); + await expect(reset).toBeEnabled(); + await reset.click(); + + // After reset: one empty leaf, button disabled. + await expect(leafInputs).toHaveCount(1); + await expect(leafInputs.first()).toHaveValue(""); + await expect(reset).toBeDisabled(); + }); + + test("Profile row renders above the QueryBuilder, even with one channel", async ({ + page, + }) => { + await page.goto("/"); + const profilesRow = page.getByTestId("profiles-row"); + const builder = page.getByTestId("query-builder"); + await expect(profilesRow).toBeVisible(); + await expect(builder).toBeVisible(); + const profileBox = await profilesRow.boundingBox(); + const builderBox = await builder.boundingBox(); + if (!profileBox || !builderBox) throw new Error("missing bounding box"); + expect(profileBox.y).toBeLessThan(builderBox.y); + }); + + test("Share button lives inside the Profile row", async ({ page }) => { + await page.goto("/"); + const profilesRow = page.getByTestId("profiles-row"); + const shareInRow = profilesRow.getByRole("button", { + name: "Share current search", + }); + await expect(shareInRow).toBeVisible(); + }); + + // A profile + URL share-v1 overlay combo that makes committed diverge from + // the active profile snapshot without going through commitSearch (which + // would otherwise clear `activeProfileName`). We seed `query` on the + // profile and pass the *same* `qt=` in the URL so the snapshot's `query` + // field matches what `committedSnapshot.query` will be — that way the + // only diverging field is the filter we want to test (nol). + const ALPHA_TREE_QS = encodeURIComponent( + JSON.stringify({ + k: "g", + o: "AND", + c: [{ k: "l", q: "alpha", s: "transcripts" }], + }), + ); + const PROFILE_QUERY = JSON.stringify({ + k: "g", + o: "AND", + c: [{ k: "l", q: "alpha", s: "transcripts" }], + }); + + async function seedDirtyProfile(page: Page) { + await page.goto("/"); + await page.evaluate( + ({ key, value }) => { + window.localStorage.setItem(key, value); + }, + { + key: STORAGE_KEY, + value: JSON.stringify({ + v: 1, + working: { + channels: { included: [], excluded: [] }, + nol: true, + query: PROFILE_QUERY, + }, + profiles: { + p1: { + channels: { included: [], excluded: [] }, + nol: true, + query: PROFILE_QUERY, + }, + }, + activeProfileName: "p1", + }), + }, + ); + + // share-v1 overlay: Livestreams selected (nol=false) so committed + // diverges from the profile (nol=true). Build the QS manually because + // URLSearchParams.toString re-encodes the already-encoded `qt`. + const qs = [ + `qt=${ALPHA_TREE_QS}`, + "fv=1", + `fc=${encodeURIComponent(CHANNEL)}`, + "ft=v", + "ft=l", + "fa=a", + "fa=r", + "fav=a", + "fav=d", + ].join("&"); + await page.goto(`/?${qs}`); + } + + test("dirty marker is a • glyph, not the word '(unsaved)'", async ({ + page, + }) => { + await seedDirtyProfile(page); + + // The profile is selected and the dirty dot is visible. + await expect(page.getByTestId("profile-select")).toHaveValue("p1"); + const dot = page.getByTestId("profile-dirty-dot"); + await expect(dot).toBeVisible(); + await expect(dot).toHaveText("•"); + // "(unsaved)" still appears as the placeholder <option>, but never as a + // sibling element next to the select. Scope the assertion to <span>s. + const profilesRow = page.getByTestId("profiles-row"); + await expect( + profilesRow.locator('span', { hasText: '(unsaved)' }), + ).toHaveCount(0); + }); + + test("Revert button restores the saved profile snapshot", async ({ + page, + }) => { + await seedDirtyProfile(page); + + const livestreams = page.getByRole("checkbox", { name: "Livestreams" }); + await expect(livestreams).toBeChecked(); // URL overlay selected it + await expect(page.getByTestId("profile-dirty-dot")).toBeVisible(); + + await page.getByTestId("profile-revert").click(); + + // Profile p1 has nol=true → Livestreams unchecked, dot gone. + await expect(livestreams).not.toBeChecked(); + await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0); + }); +}); + +// "Reset everything" lives in the channel-filters reset row, which is gated +// on `channelOptions.length > 1`. Override the summaries manifest to expose +// two channels so the button renders. +async function installTwoChannelRoutes(page: Page) { + await installRoutes(page); + const SECOND = "Second Channel"; + await page.route("**/summaries/manifest.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + version: 2, + totalCount: 3, + pageSize: 1000, + pageCount: 1, + generatedAt: new Date().toISOString(), + channels: [ + { name: CHANNEL, count: 3 }, + { name: SECOND, count: 0 }, + ], + }), + }); + }); +} + +test.describe("search UI — Reset everything (multi-channel fixture)", () => { + test.beforeEach(async ({ page }) => { + await installTwoChannelRoutes(page); + }); + + test("clears layers AND filters and drops the profile pointer", async ({ + page, + }) => { + // Seed a profile so we can verify the pointer gets nulled too. + await page.goto("/"); + await page.evaluate( + ({ key, value }) => { + window.localStorage.setItem(key, value); + }, + { + key: STORAGE_KEY, + value: JSON.stringify({ + v: 1, + working: { channels: { included: [], excluded: [] } }, + profiles: { + p1: { channels: { included: [], excluded: [] } }, + }, + activeProfileName: "p1", + }), + }, + ); + + // Reload with a non-empty `qt=` and a filter overlay so both halves of + // "everything" are non-default. + const tree = { + k: "g", + o: "AND", + c: [{ k: "l", q: "alpha", s: "transcripts" }], + }; + await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(tree))}`); + await page.getByRole("checkbox", { name: "Livestreams" }).uncheck(); + + // Sanity: the leaf input has "alpha" and the Reset everything button + // is now present (multi-channel fixture). + const leafInput = page + .locator('[data-testid^="leaf-query-"]') + .first(); + await expect(leafInput).toHaveValue("alpha"); + + await page.getByTestId("reset-everything").click(); + + // Layers reset to one empty leaf. + await expect( + page.locator('[data-testid^="leaf-query-"]'), + ).toHaveCount(1); + await expect(leafInput).toHaveValue(""); + + // Filters back to defaults — Livestreams selected again. + await expect( + page.getByRole("checkbox", { name: "Livestreams" }), + ).toBeChecked(); + + // Profile pointer cleared in storage. + await expect + .poll(async () => + page.evaluate((key) => { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + return (JSON.parse(raw) as { activeProfileName: string | null }) + .activeProfileName; + }, STORAGE_KEY), + ) + .toBeNull(); + }); +}); + diff --git a/export/e2e/share-current-search.spec.ts b/export/e2e/share-current-search.spec.ts @@ -39,8 +39,13 @@ test.describe("search page — share current search", () => { expect(tree.c[0].q).toBe("alpha"); expect(tree.c[0].s).toBe("transcripts"); - // Click Share and inspect the clipboard. - await page.getByRole("button", { name: "Share current search" }).click(); + // Click Share and inspect the clipboard. The share button lives inside + // the Profile row now — scope the lookup so a future re-introduction of + // a duplicate button elsewhere doesn't accidentally pass. + const profilesRow = page.getByTestId("profiles-row"); + await profilesRow + .getByRole("button", { name: "Share current search" }) + .click(); await expect( page.getByRole("button", { name: "Link copied!" }), ).toBeVisible();