Archilyzer · Source

archilyzer

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

commit 1964b15ee092b93d827abb36bf41be9731a80743
parent 8c10cd05de71775cd58a2986975c6d790546190d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 20 May 2026 18:51:47 -0400

Layered search

Diffstat:
Acommon/components/LayerSwatch.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/QueryBuilder.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/QueryGroupView.tsx | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/QueryLeafView.tsx | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/TranscriptSearch.tsx | 1256+++++++++++++++++++++++++++++++++----------------------------------------------
Mcommon/components/exportFilterStorage.ts | 10++++++++++
Acommon/components/searchLayerCache.ts | 308+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/searchPipeline.ts | 256++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acommon/lib/searchEval.ts | 632+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/searchQuery.ts | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Mexport/CHANGELOG.md | 1+
Aexport/e2e/query-tree.spec.ts | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/share-current-search.spec.ts | 31+++++++++++++++++++++++++------
14 files changed, 2979 insertions(+), 743 deletions(-)

diff --git a/common/components/LayerSwatch.tsx b/common/components/LayerSwatch.tsx @@ -0,0 +1,53 @@ +"use client"; + +// Per-leaf colour swatch. Each leaf in the composite-search tree gets a +// deterministic hue derived from its `id`, so the same leaf renders the +// same colour in its editor card AND on every result hit it contributes. +// Hue space is mod 360 with fixed saturation/lightness for a consistent +// palette in both light and dark themes. + +function hashStr(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return h; +} + +export function swatchHue(id: string): number { + return Math.abs(hashStr(id)) % 360; +} + +export function swatchBgStyle(id: string): React.CSSProperties { + const h = swatchHue(id); + return { + backgroundColor: `hsl(${h} 70% 55%)`, + }; +} + +export function swatchSoftBgStyle(id: string): React.CSSProperties { + const h = swatchHue(id); + return { + backgroundColor: `hsl(${h} 70% 55% / 0.14)`, + borderColor: `hsl(${h} 70% 55% / 0.45)`, + }; +} + +export function LayerSwatch({ + leafId, + size = "sm", + className = "", +}: { + leafId: string; + size?: "xs" | "sm"; + className?: string; +}) { + const dim = size === "xs" ? "h-2 w-2" : "h-2.5 w-2.5"; + return ( + <span + className={`inline-block rounded-full ${dim} shrink-0 ${className}`} + style={swatchBgStyle(leafId)} + aria-hidden="true" + /> + ); +} diff --git a/common/components/QueryBuilder.tsx b/common/components/QueryBuilder.tsx @@ -0,0 +1,129 @@ +"use client"; + +// Top-level composite-search UI. Renders the root group of a QueryNode tree +// and wires up immutable-mutation callbacks (replaceNode / removeNode / +// insertChild / wrapInGroup from `searchQuery.ts`). The parent owns the +// committed root; this component is fully controlled. +// +// When the tree is "trivially simple" — root AND group with exactly one +// non-negated leaf — the builder renders in compact mode (one input row + +// a small "Add layer" link), preserving the look of the original search bar. + +import { + insertChild, + isLeaf, + newGroup, + newLeaf, + removeNode, + replaceNode, + wrapInGroup, + type GroupNode, + type LeafNode, + type QueryNode, +} from "../lib/searchQuery"; +import type { GroupState, LeafState } from "../lib/searchEval"; +import QueryGroupView from "./QueryGroupView"; +import QueryLeafView from "./QueryLeafView"; + +type Props = { + root: GroupNode; + onChange: (next: GroupNode) => void; + leafStates: ReadonlyMap<string, LeafState>; + groupStates: ReadonlyMap<string, GroupState>; +}; + +export default function QueryBuilder({ + root, + onChange, + leafStates, + groupStates, +}: Props) { + const onChangeNode = (id: string, next: QueryNode) => { + if (id === root.id) { + if (next.kind === "group") onChange(next); + return; + } + onChange(replaceNode(root, id, next)); + }; + const onRemoveNode = (id: string) => onChange(removeNode(root, id)); + const onAddChild = (groupId: string, child: QueryNode) => + onChange(insertChild(root, groupId, child)); + const onWrapLeaf = (leafId: string) => + onChange(wrapInGroup(root, leafId, "AND")); + + // Compact mode: root AND group with one non-negated leaf, no negation + // applied to the root either. Anything else falls back to the full + // builder rendering. + const onlyChild = root.children.length === 1 ? root.children[0] : null; + const isCompactCandidate = + !root.negate && + root.op === "AND" && + onlyChild !== null && + isLeaf(onlyChild) && + !onlyChild.negate; + + if (isCompactCandidate && onlyChild && isLeaf(onlyChild)) { + const leaf = onlyChild as LeafNode; + return ( + <div className="flex flex-col gap-2" data-testid="query-builder"> + <QueryLeafView + leaf={leaf} + state={leafStates.get(leaf.id)} + compact + canDelete={false} + canNegate={false} + onChange={(next) => onChangeNode(leaf.id, next)} + onDelete={() => { + /* not reachable: canDelete=false in compact mode */ + }} + /> + <div className="flex items-center gap-2 text-xs"> + <button + type="button" + onClick={() => + onAddChild( + root.id, + newLeaf({ scope: "transcripts", contributeHits: true }), + ) + } + data-testid="compact-add-layer" + className="underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + + Add layer + </button> + <button + type="button" + onClick={() => + onAddChild( + root.id, + newGroup({ + op: "OR", + children: [newLeaf({ scope: "transcripts" })], + }), + ) + } + className="underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + + Add group + </button> + </div> + </div> + ); + } + + return ( + <div data-testid="query-builder"> + <QueryGroupView + group={root} + isRoot + depth={0} + leafStates={leafStates} + groupStates={groupStates} + onChangeNode={onChangeNode} + onRemoveNode={onRemoveNode} + onAddChild={onAddChild} + onWrapLeaf={onWrapLeaf} + /> + </div> + ); +} diff --git a/common/components/QueryGroupView.tsx b/common/components/QueryGroupView.tsx @@ -0,0 +1,171 @@ +"use client"; + +// Group container for the composite search builder. Renders its op +// (AND / OR), optional NOT flag, live count, and children (leaves or +// nested groups). When the root group is in compact mode (single leaf, +// no negation), the parent renders just the leaf directly without this +// wrapper to keep the simple-search case visually minimal. + +import { + isGroup, + isLeaf, + newGroup, + newLeaf, + type GroupNode, + type QueryNode, +} from "../lib/searchQuery"; +import type { GroupState, LeafState } from "../lib/searchEval"; +import QueryLeafView from "./QueryLeafView"; + +type Props = { + group: GroupNode; + isRoot: boolean; + depth: number; + leafStates: ReadonlyMap<string, LeafState>; + groupStates: ReadonlyMap<string, GroupState>; + onChangeNode: (id: string, next: QueryNode) => void; + onRemoveNode: (id: string) => void; + onAddChild: (groupId: string, child: QueryNode) => void; + onWrapLeaf: (leafId: string) => void; +}; + +export default function QueryGroupView({ + group, + isRoot, + depth, + leafStates, + groupStates, + onChangeNode, + onRemoveNode, + onAddChild, + onWrapLeaf, +}: Props) { + const state = groupStates.get(group.id); + const onlyLeafCount = group.children.filter(isLeaf).length; + + const indent = depth === 0 ? "" : "pl-4 border-l-2 border-zinc-200 dark:border-zinc-800"; + + return ( + <div + className={`flex flex-col gap-2 ${indent}`} + data-group-id={group.id} + data-group-op={group.op} + > + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> + <select + value={group.op} + onChange={(e) => + onChangeNode(group.id, { ...group, op: e.target.value as "AND" | "OR" }) + } + data-testid={`group-op-${group.id}`} + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs font-medium" + > + <option value="AND">ALL of (AND)</option> + <option value="OR">ANY of (OR)</option> + </select> + {!isRoot && ( + <label className="flex items-center gap-1 select-none"> + <input + type="checkbox" + checked={group.negate} + onChange={(e) => + onChangeNode(group.id, { ...group, negate: e.target.checked }) + } + className="accent-red-500" + data-testid={`group-negate-${group.id}`} + /> + NOT + </label> + )} + {state && ( + <span + className="text-zinc-500 dark:text-zinc-400" + data-testid={`group-count-${group.id}`} + > + → {state.slugCount} video{state.slugCount === 1 ? "" : "s"} + </span> + )} + {!isRoot && ( + <button + type="button" + onClick={() => onRemoveNode(group.id)} + aria-label="Remove group" + data-testid={`group-delete-${group.id}`} + className="ml-auto text-xs rounded border border-zinc-300 dark:border-zinc-700 w-6 h-6 inline-flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-zinc-800" + > + × + </button> + )} + </div> + + <div className="flex flex-col gap-2"> + {group.children.map((child) => { + if (isLeaf(child)) { + return ( + <QueryLeafView + key={child.id} + leaf={child} + state={leafStates.get(child.id)} + compact={false} + canDelete={group.children.length > 1 || !isRoot} + onChange={(next) => onChangeNode(child.id, next)} + onDelete={() => onRemoveNode(child.id)} + onWrapInGroup={() => onWrapLeaf(child.id)} + isOnlyLeaf={onlyLeafCount === 1} + /> + ); + } + if (isGroup(child)) { + return ( + <QueryGroupView + key={child.id} + group={child} + isRoot={false} + depth={depth + 1} + leafStates={leafStates} + groupStates={groupStates} + onChangeNode={onChangeNode} + onRemoveNode={onRemoveNode} + onAddChild={onAddChild} + onWrapLeaf={onWrapLeaf} + /> + ); + } + return null; + })} + </div> + + <div className="flex flex-wrap items-center gap-2 text-xs"> + <button + type="button" + onClick={() => + onAddChild( + group.id, + newLeaf({ scope: "transcripts", contributeHits: true }), + ) + } + data-testid={`group-add-leaf-${group.id}`} + className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800" + > + + Add layer + </button> + <button + type="button" + onClick={() => + onAddChild( + group.id, + newGroup({ + op: "OR", + children: [newLeaf({ scope: "transcripts" })], + }), + ) + } + data-testid={`group-add-group-${group.id}`} + className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800" + > + + Add group + </button> + </div> + </div> + ); +} diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx @@ -0,0 +1,231 @@ +"use client"; + +// Single-leaf editor card for the composite search builder. +// +// Renders one row when the parent says "compact mode" (the simple-search +// case: root is one AND group with one leaf, no negation), otherwise a +// boxed card with all controls visible. The card shows its layer swatch, +// scope dropdown, query input, regex / show-hits / NOT toggles, a live +// "→ N videos" count, a cached badge when applicable, and a delete button. + +import type { LeafNode, LayerScope } from "../lib/searchQuery"; +import type { LeafState } from "../lib/searchEval"; +import { LayerSwatch, swatchSoftBgStyle } from "./LayerSwatch"; + +type Props = { + leaf: LeafNode; + state: LeafState | undefined; + compact?: boolean; + canDelete: boolean; + canNegate?: boolean; + isOnlyLeaf?: boolean; + onChange: (next: LeafNode) => void; + onDelete: () => void; + onWrapInGroup?: () => void; +}; + +const SCOPE_LABELS: Record<LayerScope, string> = { + transcripts: "Transcripts", + chat: "Live chat", + metadata: "Title / channel", +}; + +const SCOPE_COST: Record<LayerScope, string> = { + transcripts: "slow", + chat: "medium", + metadata: "fast", +}; + +export default function QueryLeafView({ + leaf, + state, + compact = false, + canDelete, + canNegate = true, + onChange, + onDelete, + onWrapInGroup, +}: Props) { + const update = (patch: Partial<LeafNode>) => + onChange({ ...leaf, ...patch }); + + const regexInvalid = + leaf.useRegex && leaf.query.trim() !== "" && !isValidRegex(leaf.query); + + const placeholder = + leaf.scope === "metadata" + ? leaf.useRegex + ? "Title/channel regex..." + : "Match against title or channel..." + : leaf.scope === "chat" + ? leaf.useRegex + ? "Live chat regex..." + : "Search live chat..." + : leaf.useRegex + ? "Regex pattern (case-insensitive)..." + : "Search transcripts..."; + + const queryInput = ( + <input + type="search" + value={leaf.query} + onChange={(e) => update({ query: e.target.value })} + placeholder={placeholder} + data-testid={`leaf-query-${leaf.id}`} + className={`flex-1 min-w-0 rounded-lg border ${ + regexInvalid + ? "border-red-500" + : "border-zinc-300 dark:border-zinc-700" + } bg-white dark:bg-zinc-900 px-3 py-2 text-base outline-none focus:ring-2 focus:ring-blue-500 ${ + leaf.useRegex ? "font-mono" : "" + }`} + /> + ); + + const scopeSelect = ( + <label className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-400"> + <select + value={leaf.scope} + onChange={(e) => update({ scope: e.target.value as LayerScope })} + data-testid={`leaf-scope-${leaf.id}`} + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs" + > + {(["transcripts", "chat", "metadata"] as const).map((s) => ( + <option key={s} value={s}> + {SCOPE_LABELS[s]} + </option> + ))} + </select> + <span className="text-[10px] uppercase tracking-wide text-zinc-400"> + {SCOPE_COST[leaf.scope]} + </span> + </label> + ); + + const regexToggle = ( + <label className="flex items-center gap-1 text-xs select-none"> + <input + type="checkbox" + checked={leaf.useRegex} + onChange={(e) => update({ useRegex: e.target.checked })} + className="accent-blue-600" + data-testid={`leaf-regex-${leaf.id}`} + /> + Regex + </label> + ); + + const countBadge = state ? ( + <span + className="ml-auto text-xs text-zinc-500 dark:text-zinc-400 shrink-0" + data-testid={`leaf-count-${leaf.id}`} + > + → {state.slugCount} video{state.slugCount === 1 ? "" : "s"} + {state.active ? "…" : ""} + </span> + ) : null; + + const cachedBadge = + state?.cached && !state.active ? ( + <span + className="ml-1 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300" + data-cache-hit={leaf.id} + title="Served from layer cache" + > + cached + </span> + ) : null; + + if (compact) { + return ( + <div className="flex flex-col gap-1.5"> + <div className="flex items-center gap-2"> + <LayerSwatch leafId={leaf.id} /> + {queryInput} + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400"> + {scopeSelect} + {regexToggle} + {countBadge} + {cachedBadge} + </div> + </div> + ); + } + + return ( + <div + className="flex flex-col gap-2 rounded-lg border bg-white/60 dark:bg-zinc-900/40 px-3 py-2" + style={swatchSoftBgStyle(leaf.id)} + data-leaf-id={leaf.id} + > + <div className="flex items-center gap-2"> + <LayerSwatch leafId={leaf.id} /> + {queryInput} + {countBadge} + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> + {scopeSelect} + {regexToggle} + <label className="flex items-center gap-1 select-none"> + <input + type="checkbox" + checked={leaf.contributeHits} + onChange={(e) => update({ contributeHits: e.target.checked })} + className="accent-blue-600" + data-testid={`leaf-hits-${leaf.id}`} + /> + Show hits in results + </label> + {canNegate && ( + <label className="flex items-center gap-1 select-none"> + <input + type="checkbox" + checked={leaf.negate} + onChange={(e) => update({ negate: e.target.checked })} + className="accent-red-500" + data-testid={`leaf-negate-${leaf.id}`} + /> + NOT + </label> + )} + {cachedBadge} + <div className="ml-auto flex items-center gap-1"> + {onWrapInGroup && ( + <button + type="button" + onClick={onWrapInGroup} + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + title="Wrap this layer in a sub-group so you can add OR siblings" + data-testid={`leaf-wrap-${leaf.id}`} + > + wrap + </button> + )} + <button + type="button" + onClick={onDelete} + disabled={!canDelete} + aria-label="Remove layer" + data-testid={`leaf-delete-${leaf.id}`} + className="text-xs rounded border border-zinc-300 dark:border-zinc-700 w-6 h-6 inline-flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed" + > + × + </button> + </div> + </div> + {regexInvalid && ( + <p className="text-xs text-red-600 font-mono">Invalid regex</p> + )} + </div> + ); +} + +function isValidRegex(s: string): boolean { + try { + new RegExp(s); + return true; + } catch { + return false; + } +} diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -4,14 +4,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePlayer } from "./PlayerProvider"; import { useSummaries } from "./summariesCache"; import { useChannelSubsManifests, useSubsManifest } from "./subsCache"; +import type { LayerHit } from "./searchPipeline"; import { - createSearchPipeline, - createSubsSearchPipeline, - filterSlugs, - type Hit, - type PipelineController, - type SubsHit, -} from "./searchPipeline"; + runQueryTree, + type GroupState, + type LeafState, + type TreeController, + type TreeProgress, +} from "../lib/searchEval"; +import { + canonicalHash, + emptyRoot, + forEachLeaf, + isNodeActive, + parseRoot, + rootFromLegacy, + stringifyRoot, + type GroupNode, +} from "../lib/searchQuery"; import { useUrlParams, writeUrlParams } from "./urlState"; import { buildShareSearchParams, @@ -35,6 +45,8 @@ import { saveAdvanced, } from "./exportAdvancedStorage"; import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; +import QueryBuilder from "./QueryBuilder"; +import { LayerSwatch } from "./LayerSwatch"; import type { DisplaySummary } from "../lib/transcripts"; import { DEFAULT_GROUP_FALLBACK_ID, @@ -46,26 +58,14 @@ import { type Summary = DisplaySummary; -type HitGroup = { +type ResultGroup = { slug: string; title: string; channel: string; date: string; isLivestream: boolean; ageRestricted: boolean; - titleMatch?: string; - hits: Hit[]; -}; - -type SubsHitGroup = { - slug: string; - title: string; - channel: string; - date: string; - isLivestream: boolean; - ageRestricted: boolean; - titleMatch?: string; - hits: SubsHit[]; + hits: LayerHit[]; }; const DEFAULT_MAX_HITS = 500; @@ -73,9 +73,8 @@ const DEFAULT_FETCH_CONCURRENCY = 6; const DEFAULT_FLUSH_INTERVAL_MS = 120; const DEFAULT_GROUPS_PAGE_SIZE = 50; -// URL params the export UI considers "filter state". Used by the hydration -// path to detect whether the URL is authoritative — if any of these are set, -// the URL wins over localStorage. +// 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 = [ "ch", "nov", @@ -95,8 +94,7 @@ function urlHasAnyFilterParam(): boolean { } // Resolve a stored snapshot's per-channel deltas (plus group defaults) into -// the concrete set of EXCLUDED channel names — i.e. the format already used -// by the URL `ch` param and the existing draftExcludedChannels Set. +// the concrete set of EXCLUDED channel names. function snapshotToExcluded( snapshot: FilterSnapshot | null, channelOptions: ReadonlyArray<string>, @@ -106,7 +104,7 @@ function snapshotToExcluded( const excluded = new Set(snapshot?.channels.excluded ?? []); const out = new Set<string>(); for (const name of channelOptions) { - if (included.has(name)) continue; // user override: on + if (included.has(name)) continue; if (excluded.has(name)) { out.add(name); continue; @@ -116,9 +114,6 @@ function snapshotToExcluded( return out; } -// Inverse of snapshotToExcluded: compute the explicit user-delta (included+ -// excluded) given the resolved excluded set and the group defaults. Anything -// matching the default is omitted so newly-added channels can inherit. function buildSnapshotDeltas( excludedSet: ReadonlySet<string>, channelOptions: ReadonlyArray<string>, @@ -136,18 +131,28 @@ function buildSnapshotDeltas( return { included: included.sort(), excluded: excluded.sort() }; } +function anyLeafHasScope( + root: GroupNode, + scope: "transcripts" | "chat" | "metadata", +): boolean { + let found = false; + forEachLeaf(root, (leaf) => { + if (leaf.scope === scope && leaf.query.trim()) found = true; + }); + return found; +} + export default function TranscriptSearch() { const { - q: query, - re: useRegex, + q: legacyQ, + re: legacyRe, v: activeVideo, t: activeTime, - mode, + mode: legacyMode, } = useUrlParams(); + // Committed filter state lives in React (not the URL) since filters are - // no longer auto-tracked — hydration seeds this from a share-link URL, - // a legacy URL, or localStorage, and `commitSearch`/profile-load update - // it directly. + // no longer auto-tracked. const [committedExcludedChannels, setCommittedExcludedChannels] = useState< Set<string> >(() => new Set()); @@ -157,23 +162,23 @@ export default function TranscriptSearch() { const [committedNar, setCommittedNar] = useState(false); const [committedNav, setCommittedNav] = useState(false); const [committedNd, setCommittedNd] = useState(false); - const [committedExcludedTracks, setCommittedExcludedTracks] = useState< - Set<string> - >(() => new Set()); - // Content-based keys so Set identity doesn't churn unnecessarily. const committedChannelsKey = useMemo( () => Array.from(committedExcludedChannels).sort().join(" "), [committedExcludedChannels], ); const committedChannels = committedExcludedChannels; - const committedTracksKey = useMemo( - () => Array.from(committedExcludedTracks).sort().join(" "), - [committedExcludedTracks], + + // Query tree: draft (what the user is editing) vs. committed (what is + // currently being searched). `committedHash` is a fingerprint so React + // effects can dep on it without recomputing on identity churn. + const [draftRoot, setDraftRoot] = useState<GroupNode>(emptyRoot); + const [committedRoot, setCommittedRoot] = useState<GroupNode>(emptyRoot); + const draftHash = useMemo(() => canonicalHash(draftRoot), [draftRoot]); + const committedHash = useMemo( + () => canonicalHash(committedRoot), + [committedRoot], ); - const [input, setInput] = useState(query); - useEffect(() => { - setInput(query); - }, [query]); + const queryDirty = draftHash !== committedHash; const [draftExcludedChannels, setDraftExcludedChannels] = useState< Set<string> @@ -184,15 +189,7 @@ export default function TranscriptSearch() { const [draftNar, setDraftNar] = useState(false); const [draftNav, setDraftNav] = useState(false); const [draftNd, setDraftNd] = useState(false); - const [draftRegex, setDraftRegex] = useState(useRegex); - const [draftExcludedTracks, setDraftExcludedTracks] = useState<Set<string>>( - () => new Set(), - ); - useEffect(() => setDraftRegex(useRegex), [useRegex]); - // Persisted filter state, profiles, and UI collapse — see exportFilterStorage. - // Hydration is gated until the manifest is loaded so we know which channels - // exist when applying stored deltas. const [hydrated, setHydrated] = useState(false); const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({}); const [activeProfileName, setActiveProfileName] = useState<string | null>( @@ -202,39 +199,58 @@ export default function TranscriptSearch() { () => new Set(), ); - const [searchExecuted, setSearchExecuted] = useState(() => { + // Tracks whether the user has committed at least once this session. Used + // by the placeholder copy below — until the user has searched, we show a + // "tip" hint rather than the "no matching videos" empty state. + const [, setSearchExecuted] = useState<boolean>(() => { if (typeof window === "undefined") return true; const params = new URLSearchParams(window.location.search); - return !(params.has("v") && (params.get("q") ?? "") !== ""); + return !( + params.has("v") && + !params.has("qt") && + (params.get("q") ?? "") === "" + ); }); + // Defer rendering of the QueryBuilder to the client. The builder's leaf + // IDs come from a module-scoped counter that's necessarily out of sync + // between the long-running server process and a fresh client module load, + // which would otherwise produce a hydration warning on every page load. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const { manifest, summaries, loadedPages, pageCount, summariesReady } = useSummaries(); const transcripts: Summary[] | null = summariesReady ? summaries : null; + // We need subs manifests whenever any leaf has scope=chat OR a chat leaf + // could be added (we eagerly load when the tab existed in v1). For v2 we + // only load subs manifests when a chat leaf is actually in the tree. const subsManifestQuery = useSubsManifest(); const subsManifest = subsManifestQuery.data ?? null; - // Once the subs manifest has loaded, we know whether any channel actually - // has subs. If none do, suppress the subs tab AND force-collapse subs mode - // even when ?m=subs is in the URL — there's nothing to render. - // Tab is gated on live-chat content specifically — the export UI only - // surfaces live chat in subs mode, so a manifest with only alt-language - // tracks should hide the tab entirely. const liveChatTotalCount = subsManifest?.liveChatTotalCount ?? 0; const hasSubs = subsManifest !== null && liveChatTotalCount > 0; - const isSubsMode = mode === "subs" && hasSubs; + const draftHasChatLeaf = useMemo( + () => anyLeafHasScope(draftRoot, "chat"), + [draftRoot], + ); + const committedHasChatLeaf = useMemo( + () => anyLeafHasScope(committedRoot, "chat"), + [committedRoot], + ); + const needsChatManifests = draftHasChatLeaf || committedHasChatLeaf; const subsChannelSlugs = useMemo( () => (subsManifest ? subsManifest.channels.map((c) => c.slug) : []), [subsManifest], ); const channelSubsQueries = useChannelSubsManifests( - isSubsMode ? subsChannelSlugs : [], + needsChatManifests ? subsChannelSlugs : [], ); - // Aggregate set of all sub-having slugs (channelSlug/videoId) across loaded - // channel manifests. Driven only when in subs mode. - const allSubSlugs = useMemo<Set<string>>(() => { + // Set of (channelSlug/videoId) slugs that have live_chat content, + // resolved across all per-channel subs manifests. + const chatScopeSlugs = useMemo<Set<string>>(() => { const set = new Set<string>(); - if (!isSubsMode) return set; + if (!needsChatManifests) return set; for (let i = 0; i < channelSubsQueries.length; i++) { const q = channelSubsQueries[i]; const channelSlug = subsChannelSlugs[i]; @@ -247,41 +263,17 @@ export default function TranscriptSearch() { return set; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - isSubsMode, + needsChatManifests, subsChannelSlugs, channelSubsQueries.map((q) => (q.data ? 1 : 0)).join(""), ]); const subsManifestReady = - isSubsMode && - subsManifest !== null && - channelSubsQueries.length > 0 && - channelSubsQueries.every((q) => q.data); - const availableTracks = useMemo<string[]>(() => { - if (!subsManifest) return []; - const set = new Set<string>(); - for (const c of subsManifest.channels) { - for (const t of c.tracks) set.add(t); - } - return Array.from(set).sort(); - }, [subsManifest]); - // The "Live chat" tab is scoped to the live_chat track only — any other - // tracks present in the manifest (alt-language captions, etc.) are forced - // into the excluded set so the pipeline ignores them. Replaces the - // user-controlled draftExcludedTracks for the subs-mode call site. - const liveChatOnlyExcludedTracks = useMemo<Set<string>>( - () => new Set(availableTracks.filter((t) => t !== "live_chat")), - [availableTracks], - ); + !needsChatManifests || + (subsManifest !== null && + channelSubsQueries.length > 0 && + channelSubsQueries.every((q) => q.data)); - const [hitsBySlug, setHitsBySlug] = useState<Record<string, Hit[]>>({}); - const [subsHitsBySlug, setSubsHitsBySlug] = useState< - Record<string, SubsHit[]> - >({}); - const [totalHits, setTotalHits] = useState(0); - const [processed, setProcessed] = useState(0); - const [totalToProcess, setTotalToProcess] = useState(0); - const [pipelineDone, setPipelineDone] = useState(false); - const [capped, setCapped] = useState(false); + const [treeProgress, setTreeProgress] = useState<TreeProgress | null>(null); // Advanced options — configurable from the UI, pre-filled with defaults. const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS); @@ -301,8 +293,6 @@ export default function TranscriptSearch() { ? Infinity : groupsBatchValue; - // Hydrate the advanced-options panel from localStorage once on mount. - // Independent of filter persistence — see exportAdvancedStorage.ts. const [advancedHydrated, setAdvancedHydrated] = useState(false); useEffect(() => { if (advancedHydrated) return; @@ -318,8 +308,6 @@ export default function TranscriptSearch() { setAdvancedHydrated(true); }, [advancedHydrated]); - // Persist any change to advanced options. Only after hydration so we don't - // overwrite stored values with the initial defaults on first mount. useEffect(() => { if (!advancedHydrated) return; saveAdvanced({ @@ -344,52 +332,32 @@ export default function TranscriptSearch() { const [groupsShown, setGroupsShown] = useState<number>( DEFAULT_GROUPS_PAGE_SIZE, ); - const pipelineRef = useRef<PipelineController | null>(null); + const pipelineRef = useRef<TreeController | null>(null); const { openTranscript } = usePlayer(); - const openWithMode = (slug: string, start?: number) => - openTranscript(slug, start, { mode: isSubsMode ? "chat" : "transcript" }); - const trimmed = searchExecuted ? query.trim() : ""; - - // Reset the live caps on each new search. Uses the currently configured - // batch sizes so an advanced-options change only takes effect on the next - // search (no jarring mid-search restarts when tuning). + // Reset the live caps on each new committed query. useEffect(() => { setHitLimit(hitBatchSize); setGroupsShown(groupsBatchSize); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [trimmed, useRegex]); + }, [committedHash]); - // Per-channel group lookup driven by the active manifest. Falls back to - // the synthesized default group when the manifest has no group metadata - // (older builds) — keeps the UI working on stale manifests. + // ─── Channel-grouping setup (unchanged) ─────────────────────────────────── const manifestGroups = useMemo<ChannelGroup[]>(() => { - const src = isSubsMode ? subsManifest?.groups : manifest?.groups; + const src = manifest?.groups; if (src && src.length > 0) return src; return [{ ...FALLBACK_GROUP }]; - }, [isSubsMode, manifest, subsManifest]); + }, [manifest]); const manifestDefaultGroupId = useMemo<string>(() => { - const dg = isSubsMode - ? subsManifest?.defaultGroupId - : manifest?.defaultGroupId; + const dg = manifest?.defaultGroupId; if (typeof dg === "string" && manifestGroups.some((g) => g.id === dg)) { return dg; } return manifestGroups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID; - }, [isSubsMode, manifest, subsManifest, manifestGroups]); + }, [manifest, manifestGroups]); const channelGroupByName = useMemo<Map<string, string>>(() => { const map = new Map<string, string>(); - if (isSubsMode && subsManifest) { - for (const c of subsManifest.channels) { - if (!c.name) continue; - map.set( - c.name, - resolveChannelGroupId(c.groupId, manifestGroups, manifestDefaultGroupId), - ); - } - return map; - } if (manifest?.channels.length) { for (const c of manifest.channels) { if (!c.name) continue; @@ -400,22 +368,9 @@ export default function TranscriptSearch() { } } return map; - }, [ - isSubsMode, - manifest, - subsManifest, - manifestGroups, - manifestDefaultGroupId, - ]); + }, [manifest, manifestGroups, manifestDefaultGroupId]); const channelOptions = useMemo<string[]>(() => { - if (isSubsMode) { - if (!subsManifest) return []; - return subsManifest.channels - .map((c) => c.name) - .filter((n) => n) - .sort((a, b) => a.localeCompare(b)); - } if (manifest?.channels.length) { return manifest.channels .map((c) => c.name) @@ -426,10 +381,8 @@ export default function TranscriptSearch() { const names = new Set<string>(); for (const t of transcripts) if (t.channel) names.add(t.channel); return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [isSubsMode, manifest, subsManifest, transcripts]); + }, [manifest, transcripts]); - // Bucketed view: each group + the channels currently in it. Empty groups - // are hidden so the UI doesn't render a header with nothing inside. const channelGroupings = useMemo< { group: ChannelGroup; channels: string[] }[] >(() => { @@ -448,13 +401,14 @@ export default function TranscriptSearch() { for (const g of sorted) { const channels = byId.get(g.id); if (!channels || channels.length === 0) continue; - out.push({ group: g, channels: channels.slice().sort((a, b) => a.localeCompare(b)) }); + out.push({ + group: g, + channels: channels.slice().sort((a, b) => a.localeCompare(b)), + }); } return out; }, [channelOptions, channelGroupByName, manifestGroups, manifestDefaultGroupId]); - // Set of channels whose group has selectedByDefault === true. Used as the - // baseline against which user deltas (included/excluded) are computed. const defaultSelectedChannels = useMemo<Set<string>>(() => { const out = new Set<string>(); const groupById = new Map(manifestGroups.map((g) => [g.id, g])); @@ -491,147 +445,84 @@ export default function TranscriptSearch() { committedNd, ]); - // Stable signature of the filter inputs; used as a single dep in the search - // pipeline so reference-unstable upstream values don't cancel an in-flight - // search when other state changes. - const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${mode}|${committedTracksKey}`; - - const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => { - if (!trimmed || !useRegex) return { re: null, error: null }; - try { - return { re: new RegExp(trimmed, "i"), error: null }; - } catch (e) { - return { re: null, error: (e as Error).message }; - } - }, [trimmed, useRegex]); + const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}`; - const titleMatchedSlugs = useMemo<Set<string>>(() => { - const out = new Set<string>(); - if (!trimmed || !transcripts) return out; - if (useRegex && !regex.re) return out; - const re = useRegex && regex.re ? new RegExp(regex.re.source, "i") : null; - const ql = re ? "" : trimmed.toLowerCase(); - for (const t of transcripts) { - if (!passesFilter(t)) continue; - if (isSubsMode && !allSubSlugs.has(t.slug)) continue; - const hit = re ? re.test(t.title) : t.title.toLowerCase().includes(ql); - if (hit) out.add(t.slug); - } + const globalScopeSlugs = useMemo<string[]>(() => { + if (!transcripts) return []; + const out: string[] = []; + for (const t of transcripts) if (passesFilter(t)) out.push(t.slug); return out; - }, [ - trimmed, - useRegex, - regex.re, - transcripts, - passesFilter, - isSubsMode, - allSubSlugs, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transcripts, filterKey]); - // Pipeline lifecycle: create a new pipeline when inputs change, and cancel - // the prior one. hitLimit is NOT a dep here — it's pushed into the live - // pipeline via setHitLimit so bumping the cap resumes rather than restarts. + const hasActiveQuery = useMemo( + () => isNodeActive(committedRoot), + [committedRoot], + ); + + // ─── Run the query tree ──────────────────────────────────────────────────── + // Re-run whenever the committed root changes, filters change, or the + // chat-scope slug set finishes loading. hitLimit is *not* a dep — bumped + // via setHitLimit on the controller so "Show more" resumes without + // restarting traversal. useEffect(() => { pipelineRef.current?.cancel(); pipelineRef.current = null; - if (!trimmed) { - setHitsBySlug({}); - setSubsHitsBySlug({}); - setTotalHits(0); - setProcessed(0); - setTotalToProcess(0); - setPipelineDone(false); - setCapped(false); + if (!hasActiveQuery) { + setTreeProgress(null); return; } if (!transcripts) return; - if (useRegex && !regex.re) return; + if (needsChatManifests && !subsManifestReady) return; - if (isSubsMode) { - if (!subsManifestReady) return; - const slugs = filterSlugs( - transcripts, - (t) => passesFilter(t) && allSubSlugs.has(t.slug), - ); - const controller = createSubsSearchPipeline({ - slugs, - query: trimmed, - useRegex, - regex: regex.re, - excludedTracks: liveChatOnlyExcludedTracks, - initialHitLimit: hitLimit, - concurrency: fetchConcurrency, - flushIntervalMs, - emit: (u) => { - setSubsHitsBySlug(u.hitsBySlug); - setTotalHits(u.totalHits); - setProcessed(u.processed); - setTotalToProcess(u.totalToProcess); - setCapped(u.capped); - setPipelineDone(u.done); - }, - }); - pipelineRef.current = controller; - return () => { - controller.cancel(); - if (pipelineRef.current === controller) pipelineRef.current = null; - }; - } - - const slugs = filterSlugs(transcripts, passesFilter); - const controller = createSearchPipeline({ - slugs, - query: trimmed, - useRegex, - regex: regex.re, + const controller = runQueryTree({ + root: committedRoot, + globalScope: globalScopeSlugs, + summaries: transcripts, + chatScopeSlugs: needsChatManifests ? chatScopeSlugs : null, initialHitLimit: hitLimit, concurrency: fetchConcurrency, flushIntervalMs, - emit: (u) => { - setHitsBySlug(u.hitsBySlug); - setTotalHits(u.totalHits); - setProcessed(u.processed); - setTotalToProcess(u.totalToProcess); - setCapped(u.capped); - setPipelineDone(u.done); - }, + emit: (p) => setTreeProgress(p), }); pipelineRef.current = controller; - return () => { controller.cancel(); if (pipelineRef.current === controller) pipelineRef.current = null; }; - // hitLimit intentionally omitted — bumped via setHitLimit below. - // passesFilter captured via filterKey (content signature). + // hitLimit intentionally omitted — pushed via setHitLimit on the + // controller below. globalScopeSlugs identity already tracked via + // filterKey + transcripts. chatScopeSlugs identity ditto via + // chatScopeSlugsKey. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - trimmed, + committedHash, transcripts, filterKey, - useRegex, - regex.re, - isSubsMode, + needsChatManifests, subsManifestReady, - allSubSlugs, - committedExcludedTracks, + fetchConcurrency, + flushIntervalMs, ]); - // Push hitLimit bumps into the live pipeline so "Show more videos" resumes - // the existing traversal instead of restarting from slug 0. useEffect(() => { pipelineRef.current?.setHitLimit(hitLimit); }, [hitLimit]); - const groups = useMemo<HitGroup[]>(() => { - if (isSubsMode) return []; - if (!trimmed || !transcripts) return []; - const out: HitGroup[] = []; + // ─── Build the result list ──────────────────────────────────────────────── + // Walk the summaries in their natural (newest-first) order, surfacing only + // those slugs that survived the query tree. For each, attach the per-leaf + // hits collected by searchEval. Hits are already sorted by start time + // within each video. + const resultGroups = useMemo<ResultGroup[]>(() => { + if (!hasActiveQuery) return []; + if (!transcripts || !treeProgress) return []; + const matched = treeProgress.slugs; + const hitsBySlug = treeProgress.hits; + const out: ResultGroup[] = []; for (const t of transcripts) { - const hits = hitsBySlug[t.slug]; - const titleMatch = titleMatchedSlugs.has(t.slug); - if (!hits?.length && !titleMatch) continue; + if (!matched.has(t.slug)) continue; out.push({ slug: t.slug, title: t.title, @@ -639,39 +530,40 @@ export default function TranscriptSearch() { date: t.date, isLivestream: t.isLivestream, ageRestricted: t.ageRestricted, - titleMatch: titleMatch ? t.title : undefined, - hits: hits ?? [], + hits: hitsBySlug.get(t.slug) ?? [], }); } return out; - }, [isSubsMode, trimmed, transcripts, hitsBySlug, titleMatchedSlugs]); - - const subsGroups = useMemo<SubsHitGroup[]>(() => { - if (!isSubsMode) return []; - if (!trimmed || !transcripts) return []; - const out: SubsHitGroup[] = []; - for (const t of transcripts) { - const hits = subsHitsBySlug[t.slug]; - const titleMatch = titleMatchedSlugs.has(t.slug); - if (!hits?.length && !titleMatch) continue; - out.push({ - slug: t.slug, - title: t.title, - channel: t.channel, - date: t.date, - isLivestream: t.isLivestream, - ageRestricted: t.ageRestricted, - titleMatch: titleMatch ? t.title : undefined, - hits: hits ?? [], - }); + }, [hasActiveQuery, transcripts, treeProgress]); + + const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>(); + const groupStates = + treeProgress?.groupStates ?? new Map<string, GroupState>(); + const totalHits = useMemo(() => { + let n = 0; + for (const list of treeProgress?.hits?.values() ?? []) n += list.length; + return n; + }, [treeProgress]); + const capped = treeProgress?.capped ?? false; + const pipelineDone = treeProgress?.done ?? !hasActiveQuery; + const totalToProcess = globalScopeSlugs.length; + const processed = useMemo(() => { + if (!treeProgress) return 0; + // For "1/N searched" the user really wants to know root-level progress. + // Use the maximum over all leaves' `processed` since AND chains evaluate + // sequentially and the bottleneck leaf is the front-runner indicator. + let max = 0; + for (const s of treeProgress.leafStates.values()) { + if (s.totalToProcess === 0) continue; + const frac = s.processed / s.totalToProcess; + max = Math.max(max, frac * totalToProcess); } - return out; - }, [isSubsMode, trimmed, transcripts, subsHitsBySlug, titleMatchedSlugs]); + return Math.round(max); + }, [treeProgress, totalToProcess]); const indexLoading = - Boolean(trimmed) && - (!summariesReady || (isSubsMode && !subsManifestReady)); - const searching = Boolean(trimmed) && totalToProcess > 0; + hasActiveQuery && (!summariesReady || (needsChatManifests && !subsManifestReady)); + const searching = hasActiveQuery && totalToProcess > 0; const pipelineActive = searching && !pipelineDone; const indexProgress = pageCount > 0 ? `${loadedPages}/${pageCount} pages` : ""; @@ -683,14 +575,8 @@ export default function TranscriptSearch() { draftNaa !== committedNaa || draftNar !== committedNar || draftNav !== committedNav || - draftNd !== committedNd || - draftRegex !== useRegex || - (isSubsMode && - !sameSet(draftExcludedTracks, committedExcludedTracks)); - const queryDirty = input.trim() !== (searchExecuted ? query.trim() : ""); - - // Snapshot of the currently-committed filter state, in storage format. - // Used to detect profile dirtiness vs the in-memory committed state. + draftNd !== committedNd; + const committedSnapshot = useMemo<FilterSnapshot>(() => { const deltas = buildSnapshotDeltas( committedChannels, @@ -704,10 +590,7 @@ export default function TranscriptSearch() { if (committedNar) snap.nar = true; if (committedNav) snap.nav = true; if (committedNd) snap.nd = true; - if (mode === "subs") snap.mode = "subs"; - if (committedExcludedTracks.size > 0) { - snap.tracks = Array.from(committedExcludedTracks).sort(); - } + snap.query = stringifyRoot(committedRoot); return snap; }, [ committedChannels, @@ -719,13 +602,9 @@ export default function TranscriptSearch() { committedNar, committedNav, committedNd, - mode, - committedExcludedTracks, + committedRoot, ]); - // Snapshot built from the *draft* state, used by Save/Save-as so the - // profile captures what the user currently sees in the UI rather than - // whatever was last committed via Enter. const buildDraftSnapshot = useCallback((): FilterSnapshot => { const snap: FilterSnapshot = { channels: buildSnapshotDeltas( @@ -740,10 +619,7 @@ export default function TranscriptSearch() { if (draftNar) snap.nar = true; if (draftNav) snap.nav = true; if (draftNd) snap.nd = true; - if (mode === "subs") snap.mode = "subs"; - if (isSubsMode && draftExcludedTracks.size > 0) { - snap.tracks = Array.from(draftExcludedTracks).sort(); - } + snap.query = stringifyRoot(draftRoot); return snap; }, [ draftExcludedChannels, @@ -755,14 +631,9 @@ export default function TranscriptSearch() { draftNar, draftNav, draftNd, - mode, - isSubsMode, - draftExcludedTracks, + draftRoot, ]); - // Promote draft filter state to committed (in-memory) and strip any - // filter-related URL params. Extracted from commitSearch so profile - // save/save-as can refresh search without touching the query. const promoteDraftsToCommitted = useCallback(() => { setCommittedExcludedChannels(new Set(draftExcludedChannels)); setCommittedNov(draftNov); @@ -771,10 +642,6 @@ export default function TranscriptSearch() { setCommittedNar(draftNar); setCommittedNav(draftNav); setCommittedNd(draftNd); - // Live-chat tab is scoped to live_chat only; per-track exclusions are - // never user-controllable here so committed tracks always clear. - setCommittedExcludedTracks(new Set()); - writeUrlParams({ re: draftRegex }); stripAllFilterParamsFromUrl(); }, [ draftExcludedChannels, @@ -784,16 +651,15 @@ export default function TranscriptSearch() { draftNar, draftNav, draftNd, - draftRegex, ]); - // Hydrate once the channel list is available. Resolution order: - // 1. New share-link schema (`?fv=1&fc=…`) wins. - // 2. Legacy URL filter params (`?ch=&nov=…`) — kept for backward-compat; - // will be removed in a future cleanup. - // 3. Otherwise, the active profile (or working snapshot) from localStorage. - // The URL is NOT updated as a side effect of hydration — filters live in - // React state now, and only the explicit Share button writes filter params. + // ─── Hydration ──────────────────────────────────────────────────────────── + // Resolution order: + // 1. `qt=` in URL (modern composite-query share). + // 2. Legacy `q/m/re` URL (auto-migrate to a single-leaf root). + // 3. Share-link v1 schema (`?fv=1&fc=…`) for global filters only. + // 4. Legacy URL filter params (`?ch=&nov=…`). + // 5. Otherwise, active profile / working snapshot in localStorage. useEffect(() => { if (hydrated) return; if (channelOptions.length === 0) return; @@ -804,91 +670,95 @@ export default function TranscriptSearch() { setCollapsedGroups(new Set(stored.collapsedGroups ?? [])); } const search = typeof window !== "undefined" ? window.location.search : ""; + const params = new URLSearchParams(search); + + // Query tree. + const qt = params.get("qt"); + let rootFromUrl: GroupNode | null = null; + if (qt) { + rootFromUrl = parseRoot(qt); + } else if (legacyQ || legacyMode === "subs") { + rootFromUrl = rootFromLegacy(legacyQ, legacyMode, legacyRe); + } + + // Global filters (share-v1 wins over legacy keys wins over snapshot). + let initialExcluded: Set<string> | null = null; + let initialNov = false; + let initialNol = false; + let initialNaa = false; + let initialNar = false; + let initialNav = false; + let initialNd = false; + if (hasShareV1(search)) { const sel = parseShareV1(search, channelOptions); const excluded = new Set<string>(); for (const name of channelOptions) { if (!sel.selectedChannels.has(name)) excluded.add(name); } - setDraftExcludedChannels(excluded); - setDraftNov(!sel.videos); - setDraftNol(!sel.livestreams); - setDraftNaa(!sel.allAges); - setDraftNar(!sel.restricted); - setDraftNav(!sel.available); - setDraftNd(!sel.deleted); - setDraftExcludedTracks(new Set(sel.tracks)); - setCommittedExcludedChannels(excluded); - setCommittedNov(!sel.videos); - setCommittedNol(!sel.livestreams); - setCommittedNaa(!sel.allAges); - setCommittedNar(!sel.restricted); - setCommittedNav(!sel.available); - setCommittedNd(!sel.deleted); - setCommittedExcludedTracks(new Set(sel.tracks)); - setHydrated(true); - return; + initialExcluded = excluded; + initialNov = !sel.videos; + initialNol = !sel.livestreams; + initialNaa = !sel.allAges; + initialNar = !sel.restricted; + initialNav = !sel.available; + initialNd = !sel.deleted; + } else if (urlHasAnyFilterParam()) { + const excluded = new Set(params.getAll("ch")); + initialExcluded = excluded; + initialNov = params.get("nov") === "1"; + initialNol = params.get("nol") === "1"; + initialNaa = params.get("naa") === "1"; + initialNar = params.get("nar") === "1"; + initialNav = params.get("nav") === "1"; + initialNd = params.get("nd") === "1"; } - if (urlHasAnyFilterParam()) { - // Legacy schema: parse the URL the old way, seed committed + draft. - const p = new URLSearchParams(search); - const excluded = new Set(p.getAll("ch")); - const lNov = p.get("nov") === "1"; - const lNol = p.get("nol") === "1"; - const lNaa = p.get("naa") === "1"; - const lNar = p.get("nar") === "1"; - const lNav = p.get("nav") === "1"; - const lNd = p.get("nd") === "1"; - const lTracks = new Set(p.getAll("tk")); - setDraftExcludedChannels(excluded); - setDraftNov(lNov); - setDraftNol(lNol); - setDraftNaa(lNaa); - setDraftNar(lNar); - setDraftNav(lNav); - setDraftNd(lNd); - setDraftExcludedTracks(lTracks); - setCommittedExcludedChannels(excluded); - setCommittedNov(lNov); - setCommittedNol(lNol); - setCommittedNaa(lNaa); - setCommittedNar(lNar); - setCommittedNav(lNav); - setCommittedNd(lNd); - setCommittedExcludedTracks(lTracks); - setHydrated(true); - return; + + let resolvedRoot = rootFromUrl; + if (!initialExcluded) { + // Fall back to stored profile / working snapshot. + const snapshot = + stored?.activeProfileName != null + ? stored.profiles[stored.activeProfileName] ?? stored.working + : stored?.working ?? null; + const excluded = snapshotToExcluded( + snapshot, + channelOptions, + defaultSelectedChannels, + ); + initialExcluded = excluded; + initialNov = snapshot?.nov === true; + initialNol = snapshot?.nol === true; + initialNaa = snapshot?.naa === true; + initialNar = snapshot?.nar === true; + initialNav = snapshot?.nav === true; + initialNd = snapshot?.nd === true; + if (!resolvedRoot && snapshot?.query) { + resolvedRoot = parseRoot(snapshot.query); + } } - const snapshot = - stored?.activeProfileName != null - ? stored.profiles[stored.activeProfileName] ?? stored.working - : stored?.working ?? null; - const excluded = snapshotToExcluded( - snapshot, - channelOptions, - defaultSelectedChannels, - ); - setDraftExcludedChannels(excluded); - setCommittedExcludedChannels(excluded); - if (snapshot) { - setDraftNov(snapshot.nov === true); - setDraftNol(snapshot.nol === true); - setDraftNaa(snapshot.naa === true); - setDraftNar(snapshot.nar === true); - setDraftNav(snapshot.nav === true); - setDraftNd(snapshot.nd === true); - setDraftExcludedTracks(new Set(snapshot.tracks ?? [])); - setCommittedNov(snapshot.nov === true); - setCommittedNol(snapshot.nol === true); - setCommittedNaa(snapshot.naa === true); - setCommittedNar(snapshot.nar === true); - setCommittedNav(snapshot.nav === true); - setCommittedNd(snapshot.nd === true); - setCommittedExcludedTracks(new Set(snapshot.tracks ?? [])); + + if (resolvedRoot) { + setDraftRoot(resolvedRoot); + setCommittedRoot(resolvedRoot); + setSearchExecuted(true); } - if (snapshot?.mode === "subs") writeUrlParams({ mode: "subs" }); + + setDraftExcludedChannels(initialExcluded); + setDraftNov(initialNov); + setDraftNol(initialNol); + setDraftNaa(initialNaa); + setDraftNar(initialNar); + setDraftNav(initialNav); + setDraftNd(initialNd); + setCommittedExcludedChannels(initialExcluded); + setCommittedNov(initialNov); + setCommittedNol(initialNol); + setCommittedNaa(initialNaa); + setCommittedNar(initialNar); + setCommittedNav(initialNav); + setCommittedNd(initialNd); setHydrated(true); - // Run exactly once when the channel list becomes known. // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelOptions.length === 0, hydrated]); @@ -914,23 +784,26 @@ export default function TranscriptSearch() { const commitSearch = () => { setSearchExecuted(true); + setCommittedRoot(draftRoot); const nextSnapshot = buildDraftSnapshot(); - // Persist as the working snapshot. The active profile stays the same; - // the UI shows "(unsaved)" until the user saves explicitly. const current = loadStoredState() ?? emptyStoredState(); current.working = nextSnapshot; current.profiles = profiles; current.activeProfileName = activeProfileName; saveStoredState(current); - // Promote drafts to committed (in-memory) and strip any filter-related - // URL params left over from a share link. Only the search query and - // regex toggle are persisted on the URL. promoteDraftsToCommitted(); - writeUrlParams({ q: input.trim() }); + // Write the URL: `qt=` is canonical. Strip legacy `q`/`m`/`re` so old + // params don't clobber the next reload's tree parse. + const serialized = stringifyRoot(draftRoot); + if (isNodeActive(draftRoot)) { + writeUrlParams({ q: "", re: false, mode: "transcripts" }); + writeQtParam(serialized); + } else { + writeUrlParams({ q: "", re: false, mode: "transcripts" }); + writeQtParam(""); + } }; - // Apply an arbitrary snapshot to *draft* state only. Used by reset flows - // that should defer search re-run until the user clicks Search / Enter. const applyDraftSnapshot = useCallback( (snapshot: FilterSnapshot | null) => { const excluded = snapshotToExcluded( @@ -945,13 +818,14 @@ export default function TranscriptSearch() { setDraftNar(snapshot?.nar === true); setDraftNav(snapshot?.nav === true); setDraftNd(snapshot?.nd === true); - setDraftExcludedTracks(new Set(snapshot?.tracks ?? [])); + if (snapshot?.query) { + const parsed = parseRoot(snapshot.query); + if (parsed) setDraftRoot(parsed); + } }, [channelOptions, defaultSelectedChannels], ); - // Apply an arbitrary snapshot to draft state AND immediately commit it. - // Used by profile selection (explicit "I want this now" intent). const applySnapshot = useCallback( (snapshot: FilterSnapshot | null) => { applyDraftSnapshot(snapshot); @@ -967,10 +841,10 @@ export default function TranscriptSearch() { setCommittedNar(snapshot?.nar === true); setCommittedNav(snapshot?.nav === true); setCommittedNd(snapshot?.nd === true); - setCommittedExcludedTracks(new Set(snapshot?.tracks ?? [])); - writeUrlParams({ - mode: snapshot?.mode === "subs" ? "subs" : "transcripts", - }); + if (snapshot?.query) { + const parsed = parseRoot(snapshot.query); + if (parsed) setCommittedRoot(parsed); + } stripAllFilterParamsFromUrl(); }, [applyDraftSnapshot, channelOptions, defaultSelectedChannels], @@ -988,11 +862,9 @@ export default function TranscriptSearch() { [profiles, activeProfileName, collapsedGroups], ); - // Profile CRUD. Each mutation updates React state AND localStorage. const handleLoadProfile = useCallback( (name: string | "") => { if (name === "") { - // Switch to the working snapshot. setActiveProfileName(null); writeStorage((s) => { s.activeProfileName = null; @@ -1092,16 +964,11 @@ export default function TranscriptSearch() { setActiveProfileName(null); }, [activeProfileName, writeStorage]); - // True iff a profile is loaded AND the committed state diverges from it. const profileDirty = activeProfileName != null && profiles[activeProfileName] != null && !snapshotsEqual(profiles[activeProfileName], committedSnapshot); - // Reset channel selection back to group defaults. Leaves other filters - // untouched. Does NOT touch the active profile pointer — committing this - // change will just mark the profile dirty. Draft-only: the user must - // press Search / Enter to apply. const handleResetChannels = useCallback(() => { const excluded = new Set<string>(); for (const name of channelOptions) { @@ -1110,9 +977,6 @@ export default function TranscriptSearch() { setDraftExcludedChannels(excluded); }, [channelOptions, defaultSelectedChannels]); - // Reset every draft filter to a fresh state AND clear active profile. - // Collapse state is preserved (UI-only preference). Draft-only: the user - // must press Search / Enter to actually re-run the pipeline. const handleResetAllFilters = useCallback(() => { setActiveProfileName(null); writeStorage((s) => { @@ -1124,8 +988,6 @@ export default function TranscriptSearch() { const [shareCopied, setShareCopied] = useState(false); const shareResetRef = useRef<number | null>(null); - // Build a positive-encoding share link from the *committed* state. Drafts - // that haven't been committed aren't part of "the current search" yet. const handleShareCurrentSearch = useCallback(async () => { const selectedChannels = new Set<string>(); for (const name of channelOptions) { @@ -1139,13 +1001,16 @@ export default function TranscriptSearch() { restricted: !committedNar, available: !committedNav, deleted: !committedNd, - tracks: committedExcludedTracks, + tracks: new Set(), }; const params = buildShareSearchParams(selection, { - q: query, - mode, - regex: useRegex, + q: "", + mode: "transcripts", + regex: false, }); + if (isNodeActive(committedRoot)) { + params.set("qt", stringifyRoot(committedRoot)); + } const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; try { await navigator.clipboard.writeText(url); @@ -1169,19 +1034,11 @@ export default function TranscriptSearch() { committedNar, committedNav, committedNd, - committedExcludedTracks, - query, - mode, - useRegex, + committedRoot, ]); - // Select-all / select-none helpers. Operate on either every channel or - // a single group's channels. const setSelected = useCallback( - ( - targets: ReadonlyArray<string>, - selected: boolean, - ) => { + (targets: ReadonlyArray<string>, selected: boolean) => { setDraftExcludedChannels((prev) => { const next = new Set(prev); if (selected) { @@ -1195,82 +1052,68 @@ export default function TranscriptSearch() { [], ); + const openWithMode = (slug: string, hit?: LayerHit) => { + const modalMode = hit?.scope === "chat" ? "chat" : "transcript"; + openTranscript(slug, hit?.start, { mode: modalMode }); + }; + + // Map of leafId → leaf (and its query) so result-row rendering can look + // up the original search terms for `<mark>` highlighting. + const leavesById = useMemo(() => { + const m = new Map< + string, + { + query: string; + useRegex: boolean; + scope: "transcripts" | "chat" | "metadata"; + } + >(); + forEachLeaf(committedRoot, (leaf) => + m.set(leaf.id, { + query: leaf.query, + useRegex: leaf.useRegex, + scope: leaf.scope, + }), + ); + return m; + }, [committedRoot]); + + // Hits split by leaf, in the order leaves appear in the tree, so the UI + // sections render in a stable order regardless of the order findHitsInCues + // discovered them. + const orderedLeafIds = useMemo<string[]>(() => { + const ids: string[] = []; + forEachLeaf(committedRoot, (leaf) => { + if (leaf.contributeHits) ids.push(leaf.id); + }); + return ids; + }, [committedRoot]); + return ( <div className="flex flex-col gap-6"> - {hasSubs && ( - <div - role="tablist" - aria-label="Search mode" - className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 text-sm" - > - <button - type="button" - role="tab" - aria-selected={!isSubsMode} - onClick={() => { - if (!isSubsMode) return; - writeUrlParams({ mode: "transcripts" }); - }} - className={`px-3 py-2 -mb-px border-b-2 ${ - isSubsMode - ? "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - : "border-blue-500 text-zinc-900 dark:text-zinc-100 font-medium" - }`} - > - Transcripts - </button> - <button - type="button" - role="tab" - aria-selected={isSubsMode} - onClick={() => { - if (isSubsMode) return; - writeUrlParams({ mode: "subs" }); - }} - className={`px-3 py-2 -mb-px border-b-2 ${ - isSubsMode - ? "border-blue-500 text-zinc-900 dark:text-zinc-100 font-medium" - : "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - }`} - > - Live chat - <span className="ml-1.5 text-xs text-zinc-500"> - ({liveChatTotalCount}) - </span> - </button> - </div> - )} <form onSubmit={(e) => { e.preventDefault(); commitSearch(); }} - className="flex flex-col gap-1 -mt-3" + className="flex flex-col gap-3" + data-testid="search-form" > - <div className="flex gap-2"> - <input - type="search" - value={input} - onChange={(e) => setInput(e.target.value)} - placeholder={ - draftRegex - ? "Regex pattern (case-insensitive)…" - : "Search transcripts..." - } - className={`flex-1 rounded-lg border ${ - regex.error - ? "border-red-500" - : queryDirty || filtersDirty - ? "border-amber-400 ring-1 ring-amber-300/60 dark:ring-amber-500/40" - : "border-zinc-300 dark:border-zinc-700" - } bg-white dark:bg-zinc-900 px-4 py-3 text-base outline-none focus:ring-2 focus:ring-blue-500 ${ - draftRegex ? "font-mono" : "" - }`} + {mounted ? ( + <QueryBuilder + root={draftRoot} + onChange={setDraftRoot} + leafStates={leafStates} + groupStates={groupStates} /> + ) : ( + <div className="h-12 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/40" /> + )} + <div className="flex items-center gap-2 flex-wrap"> <button type="submit" - aria-label="Search" - className={`shrink-0 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${ + data-testid="search-submit" + className={`shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ queryDirty || filtersDirty ? "bg-blue-600 text-white hover:bg-blue-700" : "border border-zinc-300 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800" @@ -1278,26 +1121,21 @@ export default function TranscriptSearch() { > Search </button> - </div> - <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 select-none"> - <input - type="checkbox" - checked={draftRegex} - onChange={(e) => setDraftRegex(e.target.checked)} - className="accent-blue-600" - /> - Regex - {regex.error && ( - <span className="text-red-600 font-mono text-xs"> - {regex.error} - </span> - )} {(queryDirty || filtersDirty) && ( - <span className="text-xs text-amber-600 dark:text-amber-400 ml-auto"> + <span className="text-xs text-amber-600 dark:text-amber-400"> Press Enter or click Search to apply </span> )} - </label> + {hasSubs && ( + <span + className="text-xs text-zinc-400 ml-auto" + title={`${liveChatTotalCount} videos have live chat`} + > + Live chat available on {liveChatTotalCount} video + {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat. + </span> + )} + </div> </form> {channelOptions.length > 0 && ( @@ -1514,9 +1352,6 @@ export default function TranscriptSearch() { </div> )} - {/* Track filter UI intentionally hidden in subs mode: the tab is - scoped to live_chat only — see force-exclusion in the pipeline. */} - <details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400"> <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"> Advanced options @@ -1590,146 +1425,27 @@ export default function TranscriptSearch() { </div> </details> - {!trimmed && ( + {!hasActiveQuery && ( <p className="text-sm text-zinc-500"> - {isSubsMode - ? "Enter a query to search live chat." - : "Enter a query to search transcripts."} + Add a layer above to search. Combine layers with AND / OR to narrow + or widen results. </p> )} - {trimmed && !isSubsMode && ( - <> - <section> - <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2"> - <span> - Transcript mentions{" "} - {totalHits > 0 - ? `(${totalHits}${capped ? "+" : ""} mention${ - totalHits === 1 ? "" : "s" - } across ${groups.length} video${ - groups.length === 1 ? "" : "s" - })` - : "(0)"} - </span> - {indexLoading && ( - <span className="text-xs text-zinc-400 font-normal"> - loading index{indexProgress ? ` (${indexProgress})` : ""}… - </span> - )} - {searching && ( - <span className="text-xs text-zinc-400 font-normal"> - searched {processed}/{totalToProcess} - {pipelineActive ? "…" : ""} - </span> - )} - </h2> - {!indexLoading && groups.length === 0 && !pipelineActive && ( - <p className="text-sm text-zinc-500">No transcript mentions.</p> - )} - {groups.length > 0 && ( - <div className="flex flex-col gap-3"> - {groups.slice(0, groupsShown).map((g) => ( - <div - key={g.slug} - className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" - > - <button - type="button" - onClick={() => openWithMode(g.slug)} - className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2" - > - <span className="font-medium truncate flex-1 min-w-0"> - {g.title} - </span> - {g.isLivestream && <LivestreamBadge />} - {g.ageRestricted && <AgeRestrictedBadge />} - <span className="text-xs text-zinc-500 shrink-0"> - {g.channel && `${g.channel} · `} - {g.date} - {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`} - </span> - </button> - <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> - {g.titleMatch && ( - <li> - <button - type="button" - onClick={() => openWithMode(g.slug)} - className="w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800" - > - <TitleBadge /> - <span className="flex-1 min-w-0 text-sm"> - {highlight(g.titleMatch, trimmed, useRegex)} - </span> - </button> - </li> - )} - {g.hits.map((h, i) => { - const isActive = - activeVideo === g.slug && activeTime === h.start; - return ( - <li key={i}> - <button - type="button" - onClick={() => openWithMode(g.slug, h.start)} - className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ - isActive - ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" - : "" - }`} - > - <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> - {formatSeconds(h.start)} - </span> - <span className="flex-1 min-w-0 text-sm"> - {highlight(h.text, trimmed, useRegex)} - </span> - </button> - </li> - ); - })} - </ul> - </div> - ))} - </div> - )} - {(groups.length > groupsShown || capped) && ( - <div className="mt-3 flex justify-center"> - <button - type="button" - onClick={() => { - setGroupsShown((n) => n + groupsBatchSize); - if (capped) setHitLimit((l) => l + hitBatchSize); - }} - className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" - > - {groups.length > groupsShown - ? `Show more videos (${groups.length - groupsShown} loaded${capped ? ", more available" : ""})` - : "Show more videos"} - </button> - </div> - )} - </section> - </> - )} - - {trimmed && isSubsMode && ( - <section> - <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2"> - <span> - Live chat mentions{" "} - {totalHits > 0 - ? `(${totalHits}${capped ? "+" : ""} mention${ - totalHits === 1 ? "" : "s" - } across ${subsGroups.length} video${ - subsGroups.length === 1 ? "" : "s" - })` + {hasActiveQuery && ( + <section data-testid="results-section"> + <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2 flex-wrap"> + <span data-testid="results-summary"> + Matching videos{" "} + {resultGroups.length > 0 + ? `(${resultGroups.length} video${ + resultGroups.length === 1 ? "" : "s" + }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})` : "(0)"} </span> {indexLoading && ( <span className="text-xs text-zinc-400 font-normal"> - loading live chat index… + loading index{indexProgress ? ` (${indexProgress})` : ""}… </span> )} {searching && ( @@ -1739,78 +1455,25 @@ export default function TranscriptSearch() { </span> )} </h2> - {!indexLoading && subsGroups.length === 0 && !pipelineActive && ( - <p className="text-sm text-zinc-500">No live chat mentions.</p> + {!indexLoading && resultGroups.length === 0 && !pipelineActive && ( + <p className="text-sm text-zinc-500">No matching videos.</p> )} - {subsGroups.length > 0 && ( + {resultGroups.length > 0 && ( <div className="flex flex-col gap-3"> - {subsGroups.slice(0, groupsShown).map((g) => ( - <div + {resultGroups.slice(0, groupsShown).map((g) => ( + <ResultCard key={g.slug} - className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" - > - <button - type="button" - onClick={() => openWithMode(g.slug)} - className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2" - > - <span className="font-medium truncate flex-1 min-w-0"> - {g.title} - </span> - {g.isLivestream && <LivestreamBadge />} - {g.ageRestricted && <AgeRestrictedBadge />} - <span className="text-xs text-zinc-500 shrink-0"> - {g.channel && `${g.channel} · `} - {g.date} - {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`} - </span> - </button> - <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> - {g.titleMatch && ( - <li> - <button - type="button" - onClick={() => openWithMode(g.slug)} - className="w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800" - > - <TitleBadge /> - <span className="flex-1 min-w-0 text-sm"> - {highlight(g.titleMatch, trimmed, useRegex)} - </span> - </button> - </li> - )} - {g.hits.map((h, i) => { - const isActive = - activeVideo === g.slug && activeTime === h.start; - return ( - <li key={i}> - <button - type="button" - onClick={() => openWithMode(g.slug, h.start)} - className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ - isActive - ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" - : "" - }`} - > - <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> - {formatSeconds(h.start)} - </span> - <TrackBadge track={h.track} /> - <span className="flex-1 min-w-0 text-sm"> - {highlight(h.text, trimmed, useRegex)} - </span> - </button> - </li> - ); - })} - </ul> - </div> + group={g} + leavesById={leavesById} + orderedLeafIds={orderedLeafIds} + activeVideo={activeVideo} + activeTime={activeTime} + openWithMode={openWithMode} + /> ))} </div> )} - {(subsGroups.length > groupsShown || capped) && ( + {(resultGroups.length > groupsShown || capped) && ( <div className="mt-3 flex justify-center"> <button type="button" @@ -1820,8 +1483,8 @@ export default function TranscriptSearch() { }} className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" > - {subsGroups.length > groupsShown - ? `Show more videos (${subsGroups.length - groupsShown} loaded${capped ? ", more available" : ""})` + {resultGroups.length > groupsShown + ? `Show more videos (${resultGroups.length - groupsShown} loaded${capped ? ", more available" : ""})` : "Show more videos"} </button> </div> @@ -1832,21 +1495,127 @@ export default function TranscriptSearch() { ); } -function TrackBadge({ track }: { track: string }) { - const label = track === "live_chat" ? "live chat" : track; +// ─── Result rendering ──────────────────────────────────────────────────────── + +function ResultCard({ + group, + leavesById, + orderedLeafIds, + activeVideo, + activeTime, + openWithMode, +}: { + group: ResultGroup; + leavesById: ReadonlyMap< + string, + { query: string; useRegex: boolean; scope: "transcripts" | "chat" | "metadata" } + >; + orderedLeafIds: ReadonlyArray<string>; + activeVideo: string | null; + activeTime: number | null; + openWithMode: (slug: string, hit?: LayerHit) => void; +}) { + // Bucket hits per contributing leaf so the user sees one section per + // layer rather than an interleaved mishmash. + const buckets = useMemo(() => { + const m = new Map<string, LayerHit[]>(); + for (const id of orderedLeafIds) m.set(id, []); + for (const h of group.hits) { + const arr = m.get(h.leafId); + if (arr) arr.push(h); + } + return m; + }, [group.hits, orderedLeafIds]); + return ( - <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 self-center"> - {label} - </span> + <div + className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" + data-result-slug={group.slug} + > + <button + type="button" + onClick={() => openWithMode(group.slug)} + className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2" + > + <span className="font-medium truncate flex-1 min-w-0"> + {group.title} + </span> + {group.isLivestream && <LivestreamBadge />} + {group.ageRestricted && <AgeRestrictedBadge />} + <span className="text-xs text-zinc-500 shrink-0"> + {group.channel && `${group.channel} · `} + {group.date} + {` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`} + </span> + </button> + <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> + {Array.from(buckets.entries()).map(([leafId, hits]) => { + if (hits.length === 0) return null; + const leafInfo = leavesById.get(leafId); + if (!leafInfo) return null; + return ( + <li key={leafId}> + <div + className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40 border-b border-zinc-200 dark:border-zinc-800" + data-leaf-section={leafId} + > + <LayerSwatch leafId={leafId} size="xs" /> + <span className="text-[10px] uppercase tracking-wide text-zinc-500"> + {leafInfo.scope === "metadata" + ? "Title / channel" + : leafInfo.scope === "chat" + ? "Live chat" + : "Transcripts"} + </span> + <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate"> + {leafInfo.query} + </span> + <span className="ml-auto text-[10px] text-zinc-500"> + {hits.length} hit{hits.length === 1 ? "" : "s"} + </span> + </div> + <ul className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60"> + {hits.map((h, i) => { + const isActive = + activeVideo === group.slug && activeTime === h.start; + return ( + <li key={i}> + <button + type="button" + onClick={() => openWithMode(group.slug, h)} + className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ + isActive + ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" + : "" + }`} + > + <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> + {h.scope === "metadata" ? "—" : formatSeconds(h.start)} + </span> + {h.track && h.track !== "live_chat" && ( + <TrackBadge track={h.track} /> + )} + <span className="flex-1 min-w-0 text-sm"> + {highlight(h.text, leafInfo.query, leafInfo.useRegex)} + </span> + </button> + </li> + ); + })} + </ul> + </li> + ); + })} + </ul> + </div> ); } -function TitleBadge() { +function TrackBadge({ track }: { track: string }) { + const label = track === "live_chat" ? "live chat" : track; return ( - <span className="shrink-0 w-16 text-[10px] uppercase tracking-wide font-medium text-zinc-600 dark:text-zinc-300 self-center"> - <span className="px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800"> - title - </span> + <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 self-center"> + {label} </span> ); } @@ -2130,3 +1899,18 @@ function highlight(text: string, q: string, useRegex = false) { } return <>{parts}</>; } + +// Write `qt=` directly. `writeUrlParams` doesn't know about the param — +// keep that helper focused on the legacy/share keys and use a small +// dedicated mutator here. +function writeQtParam(value: string): void { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + if (value) params.set("qt", value); + else params.delete("qt"); + const qs = params.toString(); + const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`; + if (next === window.location.pathname + window.location.search) return; + window.history.replaceState(window.history.state, "", next); + window.dispatchEvent(new PopStateEvent("popstate")); +} diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts @@ -11,6 +11,13 @@ const VERSION = 1; // Per-channel delta vs. group defaults. Storing the *override* (not the // resolved selection) means newly-added channels naturally inherit their // group's selectedByDefault without showing up as accidentally-unchecked. +// +// `query` is the JSON-stringified composite-search root (see +// `common/lib/searchQuery.ts:stringifyRoot`). Persisted on profiles so a +// saved profile carries both the filters AND the layered query. +// +// `mode` and `tracks` are vestigial — kept only to deserialize older v1 +// payloads without spuriously wiping them. New writes don't set them. export type FilterSnapshot = { channels: { included: string[]; excluded: string[] }; nov?: boolean; @@ -21,6 +28,7 @@ export type FilterSnapshot = { nd?: boolean; mode?: SearchMode; tracks?: string[]; + query?: string; }; export type StoredState = { @@ -70,6 +78,7 @@ function parseSnapshot(raw: unknown): FilterSnapshot | null { if (typeof r.nd === "boolean") snap.nd = r.nd; if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode; if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice(); + if (typeof r.query === "string") snap.query = r.query; return snap; } @@ -173,5 +182,6 @@ export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean { if ((a.nd ?? false) !== (b.nd ?? false)) return false; if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false; if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false; + if ((a.query ?? "") !== (b.query ?? "")) return false; return true; } diff --git a/common/components/searchLayerCache.ts b/common/components/searchLayerCache.ts @@ -0,0 +1,308 @@ +"use client"; + +// Memoization layer for the composite-search tree (searchEval.ts). +// +// Each node's evaluation is keyed by `${canonicalHash(node)}__${scopeHash}` +// — same node + same input scope = same result, regardless of where in the +// tree it sits. This is what lets the user edit a deeply-nested leaf and +// have unchanged sibling subtrees served instantly from cache. +// +// Backed by an in-memory Map (sync, fast path) plus an IndexedDB object +// store (persistent across reloads, LRU-evicted when it grows too large). +// IndexedDB writes are batched via queueMicrotask the same way as +// transcriptStore.ts. + +import type { LayerHit } from "./searchPipeline"; + +// Separate DB from `transcriptStore.ts` (`yt-dlp-transcript-browser`) so the +// two stores can evolve their schemas independently — bumping the version +// here doesn't force a coordinated upgrade in transcriptStore. +const DB_NAME = "yt-dlp-transcript-browser:search-layer-cache"; +const DB_VERSION = 1; +const STORE = "layers"; +const MAX_ENTRIES = 500; + +export type CachedResult = { + slugs: ReadonlySet<string>; + hits: ReadonlyMap<string, LayerHit[]>; +}; + +type StoredEntry = { + key: string; + slugs: string[]; + hits: Record<string, LayerHit[]>; + ts: number; +}; + +type Mode = "pending" | "ok" | "unavailable"; + +let mode: Mode = "pending"; +let dbPromise: Promise<IDBDatabase | null> | null = null; + +function openDb(): Promise<IDBDatabase | null> { + if (typeof indexedDB === "undefined") { + mode = "unavailable"; + return Promise.resolve(null); + } + if (dbPromise) return dbPromise; + dbPromise = new Promise<IDBDatabase | null>((resolve) => { + let req: IDBOpenDBRequest; + try { + req = indexedDB.open(DB_NAME, DB_VERSION); + } catch { + downgrade("open threw"); + resolve(null); + return; + } + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + const s = db.createObjectStore(STORE, { keyPath: "key" }); + s.createIndex("ts", "ts", { unique: false }); + } + }; + req.onsuccess = () => { + mode = "ok"; + resolve(req.result); + }; + req.onerror = () => { + downgrade("open failed"); + resolve(null); + }; + req.onblocked = () => { + downgrade("open blocked"); + resolve(null); + }; + }); + return dbPromise; +} + +function downgrade(reason: string): void { + if (mode === "unavailable") return; + mode = "unavailable"; + console.warn( + `[searchLayerCache] IndexedDB unavailable (${reason}); falling back to memory-only.`, + ); +} + +const memory = new Map<string, CachedResult>(); + +export function cacheKey(nodeHash: string, scopeHash: string): string { + return `${nodeHash}__${scopeHash}`; +} + +export function getCachedSync(key: string): CachedResult | null { + return memory.get(key) ?? null; +} + +export async function getCached(key: string): Promise<CachedResult | null> { + const hit = memory.get(key); + if (hit) return hit; + const stored = await idbGet(key); + if (!stored) return null; + const result: CachedResult = { + slugs: new Set(stored.slugs), + hits: hitMapFromRecord(stored.hits), + }; + memory.set(key, result); + // Touch ts so frequently-read entries float to the top of the LRU. + idbTouch(key).catch(() => { + /* swallow — touch is best-effort */ + }); + return result; +} + +export function putCached(key: string, result: CachedResult): void { + memory.set(key, result); + const entry: StoredEntry = { + key, + slugs: Array.from(result.slugs), + hits: hitRecordFromMap(result.hits), + ts: Date.now(), + }; + schedulePut(entry); +} + +export function invalidateAll(): void { + memory.clear(); + idbClearAll().catch(() => { + /* ignore */ + }); +} + +// ─── Internal helpers ─── + +function hitMapFromRecord( + record: Record<string, LayerHit[]>, +): Map<string, LayerHit[]> { + const m = new Map<string, LayerHit[]>(); + for (const [slug, hits] of Object.entries(record)) m.set(slug, hits); + return m; +} + +function hitRecordFromMap( + map: ReadonlyMap<string, LayerHit[]>, +): Record<string, LayerHit[]> { + const out: Record<string, LayerHit[]> = {}; + for (const [slug, hits] of map) out[slug] = hits; + return out; +} + +async function idbGet(key: string): Promise<StoredEntry | null> { + const db = await openDb(); + if (!db) return null; + return new Promise<StoredEntry | null>((resolve) => { + let req: IDBRequest<StoredEntry | undefined>; + try { + const tx = db.transaction(STORE, "readonly"); + req = tx.objectStore(STORE).get(key) as IDBRequest< + StoredEntry | undefined + >; + } catch { + downgrade("read tx threw"); + resolve(null); + return; + } + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => resolve(null); + }); +} + +async function idbTouch(key: string): Promise<void> { + const db = await openDb(); + if (!db) return; + return new Promise<void>((resolve) => { + let tx: IDBTransaction; + try { + tx = db.transaction(STORE, "readwrite"); + } catch { + downgrade("touch tx threw"); + resolve(); + return; + } + const store = tx.objectStore(STORE); + const getReq = store.get(key) as IDBRequest<StoredEntry | undefined>; + getReq.onsuccess = () => { + const entry = getReq.result; + if (entry) { + entry.ts = Date.now(); + try { + store.put(entry); + } catch { + /* ignore */ + } + } + }; + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + }); +} + +let pending: StoredEntry[] = []; +let flushScheduled = false; + +function schedulePut(entry: StoredEntry): void { + if (mode === "unavailable") return; + // De-dupe consecutive writes to the same key — keep only the latest. + for (let i = 0; i < pending.length; i++) { + if (pending[i].key === entry.key) { + pending[i] = entry; + if (!flushScheduled) { + flushScheduled = true; + queueMicrotask(flushPending); + } + return; + } + } + pending.push(entry); + if (flushScheduled) return; + flushScheduled = true; + queueMicrotask(flushPending); +} + +async function flushPending(): Promise<void> { + flushScheduled = false; + if (pending.length === 0) return; + const batch = pending; + pending = []; + const db = await openDb(); + if (!db) return; + try { + const tx = db.transaction(STORE, "readwrite"); + const store = tx.objectStore(STORE); + for (const e of batch) { + try { + store.put(e); + } catch { + /* per-entry failure (e.g. unclonable) shouldn't kill the batch */ + } + } + await new Promise<void>((resolve) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + }); + } catch { + downgrade("write tx threw"); + return; + } + // LRU prune. Run opportunistically; missing this round is harmless. + pruneIfNeeded().catch(() => { + /* ignore */ + }); +} + +async function pruneIfNeeded(): Promise<void> { + const db = await openDb(); + if (!db) return; + try { + const countTx = db.transaction(STORE, "readonly"); + const count = await new Promise<number>((resolve) => { + const req = countTx.objectStore(STORE).count(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(0); + }); + if (count <= MAX_ENTRIES) return; + const toRemove = count - MAX_ENTRIES; + const writeTx = db.transaction(STORE, "readwrite"); + const idx = writeTx.objectStore(STORE).index("ts"); + let removed = 0; + await new Promise<void>((resolve) => { + const cursorReq = idx.openCursor(); + cursorReq.onsuccess = () => { + const c = cursorReq.result; + if (!c || removed >= toRemove) { + resolve(); + return; + } + try { + c.delete(); + } catch { + /* ignore */ + } + removed++; + c.continue(); + }; + cursorReq.onerror = () => resolve(); + }); + } catch { + /* ignore — eviction is best-effort */ + } +} + +async function idbClearAll(): Promise<void> { + const db = await openDb(); + if (!db) return; + return new Promise<void>((resolve) => { + try { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + } catch { + resolve(); + } + }); +} diff --git a/common/components/searchPipeline.ts b/common/components/searchPipeline.ts @@ -1,11 +1,23 @@ import { fetchTranscript } from "./transcriptCache"; import { fetchSubs } from "./subsCache"; import type { DisplaySummary } from "../lib/transcripts"; +import type { LayerScope, LeafNode } from "../lib/searchQuery"; export type Hit = { start: number; text: string }; export type SubsHit = { track: string; start: number; text: string }; +// Per-leaf hit shape consumed by the composite-search tree (searchEval.ts). +// Carries enough info for the result-list UI to render the hit with its +// originating layer's swatch + scope-specific decorations. +export type LayerHit = { + leafId: string; + scope: LayerScope; + track?: string; + start: number; + text: string; +}; + export type PipelineUpdate = { hitsBySlug: Record<string, Hit[]>; totalHits: number; @@ -269,6 +281,11 @@ type SubsPipelineConfig = { regex: RegExp | null; // Tracks to exclude. Empty set means "search all tracks". excludedTracks: Set<string>; + // Optional inclusion filter. When provided, ONLY tracks in this set are + // scanned (and `excludedTracks` is irrelevant). Used by the composite- + // search "chat" scope to limit scanning to live_chat regardless of which + // other tracks a video has. + includedTracks?: Set<string> | null; initialHitLimit: number; concurrency: number; flushIntervalMs: number; @@ -284,6 +301,7 @@ export function createSubsSearchPipeline( useRegex, regex, excludedTracks, + includedTracks, initialHitLimit, concurrency, flushIntervalMs, @@ -345,7 +363,9 @@ export function createSubsSearchPipeline( if (totalSoFar < hitLimit) { const slugHits: SubsHit[] = []; for (const [track, cueList] of Object.entries(detail.tracks)) { - if (excludedTracks.has(track)) continue; + if (includedTracks) { + if (!includedTracks.has(track)) continue; + } else if (excludedTracks.has(track)) continue; if (totalSoFar + slugHits.length >= hitLimit) break; const remaining = hitLimit - totalSoFar - slugHits.length; @@ -414,3 +434,237 @@ export function createSubsSearchPipeline( }, }; } + +// ─── Leaf pipeline wrapper ─── +// Thin adapter over `createSearchPipeline` / `createSubsSearchPipeline` for +// the composite-search tree (`searchEval.ts`). One controller per leaf in the +// tree. Returns a `LeafController` that the tree orchestrator can cancel / +// resize, plus a `done` promise that resolves with the final LeafResult. +// +// Scope=metadata isn't handled here — searchEval.ts evaluates it synchronously +// over the summaries cache. This wrapper only deals with the network-backed +// scopes (transcripts, chat) where the existing worker pool earns its keep. + +export type LeafProgress = { + slugs: Set<string>; + hits: Map<string, LayerHit[]>; + totalHits: number; + processed: number; + totalToProcess: number; + capped: boolean; + done: boolean; +}; + +export type LeafResult = { + slugs: Set<string>; + hits: Map<string, LayerHit[]>; +}; + +export type LeafController = PipelineController & { + done: Promise<LeafResult>; +}; + +export function runLeafPipeline(opts: { + leaf: LeafNode; + scopeSlugs: string[]; + initialHitLimit: number; + concurrency: number; + flushIntervalMs: number; + emit: (p: LeafProgress) => void; +}): LeafController { + const { + leaf, + scopeSlugs, + initialHitLimit, + concurrency, + flushIntervalMs, + emit, + } = opts; + + const trimmed = leaf.query.trim(); + // Caller guarantees query is non-empty before invoking us (see + // isLeafActive). Stay defensive: empty queries produce an immediate done + // with no hits. + if (!trimmed) { + const empty: LeafResult = { slugs: new Set(), hits: new Map() }; + queueMicrotask(() => { + emit({ + slugs: empty.slugs, + hits: empty.hits, + totalHits: 0, + processed: 0, + totalToProcess: 0, + capped: false, + done: true, + }); + }); + return { + cancel() { + /* no-op */ + }, + setHitLimit() { + /* no-op */ + }, + done: Promise.resolve(empty), + }; + } + + const regex = compileLeafRegex(leaf); + + let resolveDone!: (r: LeafResult) => void; + const donePromise = new Promise<LeafResult>((resolve) => { + resolveDone = resolve; + }); + + // Latest seen progress, kept locally so `done` settles with the final + // payload that the consumer already saw on the last emit. + let finalSlugs = new Set<string>(); + let finalHits = new Map<string, LayerHit[]>(); + let settled = false; + + const adaptTranscriptUpdate = (u: PipelineUpdate): LeafProgress => { + const slugs = new Set<string>(); + const hits = new Map<string, LayerHit[]>(); + for (const [slug, list] of Object.entries(u.hitsBySlug)) { + if (!list || list.length === 0) continue; + slugs.add(slug); + if (leaf.contributeHits) { + hits.set( + slug, + list.map((h) => ({ + leafId: leaf.id, + scope: "transcripts" as const, + start: h.start, + text: h.text, + })), + ); + } + } + return { + slugs, + hits, + totalHits: u.totalHits, + processed: u.processed, + totalToProcess: u.totalToProcess, + capped: u.capped, + done: u.done, + }; + }; + + const adaptSubsUpdate = (u: SubsPipelineUpdate): LeafProgress => { + const slugs = new Set<string>(); + const hits = new Map<string, LayerHit[]>(); + for (const [slug, list] of Object.entries(u.hitsBySlug)) { + if (!list || list.length === 0) continue; + slugs.add(slug); + if (leaf.contributeHits) { + hits.set( + slug, + list.map((h) => ({ + leafId: leaf.id, + scope: "chat" as const, + track: h.track, + start: h.start, + text: h.text, + })), + ); + } + } + return { + slugs, + hits, + totalHits: u.totalHits, + processed: u.processed, + totalToProcess: u.totalToProcess, + capped: u.capped, + done: u.done, + }; + }; + + const onProgress = (p: LeafProgress) => { + finalSlugs = p.slugs; + finalHits = p.hits; + emit(p); + if (p.done && !settled) { + settled = true; + resolveDone({ slugs: finalSlugs, hits: finalHits }); + } + }; + + let controller: PipelineController; + if (leaf.scope === "transcripts") { + controller = createSearchPipeline({ + slugs: scopeSlugs, + query: trimmed, + useRegex: leaf.useRegex, + regex, + initialHitLimit, + concurrency, + flushIntervalMs, + emit: (u) => onProgress(adaptTranscriptUpdate(u)), + }); + } else if (leaf.scope === "chat") { + controller = createSubsSearchPipeline({ + slugs: scopeSlugs, + query: trimmed, + useRegex: leaf.useRegex, + regex, + excludedTracks: EMPTY_TRACK_SET, + includedTracks: CHAT_ONLY_TRACK_SET, + initialHitLimit, + concurrency, + flushIntervalMs, + emit: (u) => onProgress(adaptSubsUpdate(u)), + }); + } else { + // Metadata scope is handled by searchEval.ts directly. We shouldn't be + // invoked here; resolve immediately as a safety net. + const empty: LeafResult = { slugs: new Set(), hits: new Map() }; + queueMicrotask(() => { + onProgress({ + slugs: empty.slugs, + hits: empty.hits, + totalHits: 0, + processed: 0, + totalToProcess: 0, + capped: false, + done: true, + }); + }); + return { + cancel() { + /* no-op */ + }, + setHitLimit() { + /* no-op */ + }, + done: donePromise, + }; + } + + return { + cancel() { + controller.cancel(); + if (!settled) { + settled = true; + resolveDone({ slugs: finalSlugs, hits: finalHits }); + } + }, + setHitLimit(limit: number) { + controller.setHitLimit(limit); + }, + done: donePromise, + }; +} + +function compileLeafRegex(leaf: LeafNode): RegExp | null { + if (!leaf.useRegex) return null; + try { + return new RegExp(leaf.query, "i"); + } catch { + return null; + } +} + +const EMPTY_TRACK_SET: Set<string> = new Set(); +const CHAT_ONLY_TRACK_SET: Set<string> = new Set(["live_chat"]); diff --git a/common/lib/searchEval.ts b/common/lib/searchEval.ts @@ -0,0 +1,632 @@ +// Composite-search tree orchestrator. +// +// Walks a QueryNode tree (from `searchQuery.ts`), kicks off per-leaf +// pipelines (from `searchPipeline.ts:runLeafPipeline`), runs metadata-scope +// leaves synchronously against summaries, memoizes per-leaf results in +// `searchLayerCache.ts`, and emits a streaming TreeProgress that the UI +// renders. +// +// Evaluation rules (see plan): +// AND group → narrow scope left-to-right between children +// OR group → run children in parallel against the group's scope, union +// NOT (flag) → child's result becomes `parent_scope \ child_result` +// Empty / inactive subtrees act as identity (don't narrow) +// The root is always a GroupNode (AND by default). + +import { + canonicalHash, + hashSlugs, + isGroup, + isLeaf, + isLeafActive, + isNodeActive, + type GroupNode, + type LeafNode, + type QueryNode, +} from "./searchQuery"; +import { + cacheKey, + getCached, + getCachedSync, + putCached, + type CachedResult, +} from "../components/searchLayerCache"; +import { + runLeafPipeline, + type LayerHit, + type LeafController, +} from "../components/searchPipeline"; +import type { DisplaySummary } from "./transcripts"; + +export type LeafState = { + slugCount: number; + totalHits: number; + processed: number; + totalToProcess: number; + capped: boolean; + cached: boolean; + // Whether the leaf pipeline is still actively producing results. + active: boolean; +}; + +export type GroupState = { + slugCount: number; +}; + +export type TreeProgress = { + slugs: ReadonlySet<string>; + // Per-video, ordered list of hits from contributing leaves. Stable + // identity per slug so React can reuse rendered hit rows; rebuilt on + // every progress emit since it's cheap. + hits: ReadonlyMap<string, LayerHit[]>; + leafStates: ReadonlyMap<string, LeafState>; + groupStates: ReadonlyMap<string, GroupState>; + done: boolean; + capped: boolean; +}; + +export type TreeController = { + cancel(): void; + setHitLimit(limit: number): void; +}; + +type MetadataLeafScope = "transcripts" | "chat" | "metadata"; + +type EvalCtx = { + initialHitLimit: number; + concurrency: number; + flushIntervalMs: number; + metadataIndex: MetadataIndex; + // Slugs that have any subs (live_chat) content. Chat-scope leaves narrow + // their input scope by this set before running, so we don't waste fetches + // on videos that don't have a chat track at all. + chatScopeSlugs: ReadonlySet<string> | null; + // Per-leaf live result state. Reused for cache-hit instant emission and + // for incremental re-evaluation on each leaf progress. + leafResults: Map<string, { slugs: Set<string>; hits: Map<string, LayerHit[]> }>; + leafStates: Map<string, LeafState>; + controllers: Map<string, LeafController>; + cancelled: boolean; + hitLimit: number; + emitRoot: () => void; +}; + +// Pre-built metadata index from summaries. Built once per tree run and +// reused across all metadata-scope leaves so re-evaluating a metadata leaf +// edit is instant. +type MetadataIndex = { + bySlug: Map<string, DisplaySummary>; + // Lower-cased title and channel name for fast case-insensitive contains. + titleLowerBySlug: Map<string, string>; + channelLowerBySlug: Map<string, string>; +}; + +function buildMetadataIndex(summaries: DisplaySummary[]): MetadataIndex { + const bySlug = new Map<string, DisplaySummary>(); + const titleLowerBySlug = new Map<string, string>(); + const channelLowerBySlug = new Map<string, string>(); + for (const s of summaries) { + bySlug.set(s.slug, s); + titleLowerBySlug.set(s.slug, (s.title ?? "").toLowerCase()); + channelLowerBySlug.set(s.slug, (s.channel ?? "").toLowerCase()); + } + return { bySlug, titleLowerBySlug, channelLowerBySlug }; +} + +export function runQueryTree(opts: { + root: GroupNode; + globalScope: string[]; + summaries: DisplaySummary[]; + chatScopeSlugs?: ReadonlySet<string> | null; + initialHitLimit: number; + concurrency: number; + flushIntervalMs: number; + emit: (p: TreeProgress) => void; +}): TreeController { + const { + root, + globalScope, + summaries, + chatScopeSlugs = null, + initialHitLimit, + concurrency, + flushIntervalMs, + emit, + } = opts; + + const metadataIndex = buildMetadataIndex(summaries); + + let cancelled = false; + let allDone = false; + + const ctx: EvalCtx = { + initialHitLimit, + concurrency, + flushIntervalMs, + metadataIndex, + chatScopeSlugs, + leafResults: new Map(), + leafStates: new Map(), + controllers: new Map(), + cancelled: false, + hitLimit: initialHitLimit, + emitRoot: () => emitRoot(), + }; + + // Latest emission cache — used so React doesn't see identity churn on + // unchanged slug sets between progress events. + let lastEmitToken = ""; + + function emitRoot(): void { + if (cancelled) return; + const r = evaluateTree(root, new Set(globalScope), ctx); + const groupStates = new Map<string, GroupState>(); + collectGroupStates(root, new Set(globalScope), ctx, groupStates); + const capped = anyLeafCapped(ctx); + const progress: TreeProgress = { + slugs: r.slugs, + hits: r.hits, + leafStates: ctx.leafStates, + groupStates, + done: allDone, + capped, + }; + // Cheap signature to avoid duplicate emits when re-running root after + // a no-op cache touch. + const token = `${r.slugs.size}|${allDone ? 1 : 0}|${capped ? 1 : 0}|${ctx.leafStates.size}`; + if (token === lastEmitToken && !allDone) { + // still emit to refresh hits ordering; cheap, fine. + } + lastEmitToken = token; + emit(progress); + } + + // Kick off all leaf evaluations on the next microtask so callers wiring + // listeners have a chance to subscribe before the first emit fires. + Promise.resolve().then(async () => { + if (cancelled) return; + try { + await orchestrate(root, new Set(globalScope), ctx); + } catch { + /* per-leaf errors already swallowed inside */ + } + if (cancelled) return; + allDone = true; + emitRoot(); + }); + + return { + cancel() { + cancelled = true; + ctx.cancelled = true; + for (const ctrl of ctx.controllers.values()) ctrl.cancel(); + }, + setHitLimit(limit: number) { + if (cancelled) return; + if (limit <= ctx.hitLimit) return; + ctx.hitLimit = limit; + for (const ctrl of ctx.controllers.values()) ctrl.setHitLimit(limit); + }, + }; +} + +// Orchestrate evaluation: actually run leaf pipelines (or metadata sync) and +// populate ctx.leafResults / leafStates. AND children run sequentially with +// scope narrowed between them; OR children run in parallel. +async function orchestrate( + node: QueryNode, + parentScope: Set<string>, + ctx: EvalCtx, +): Promise<Set<string>> { + if (ctx.cancelled) return new Set(); + if (!isNodeActive(node)) { + // Inactive subtree → pass-through. Don't narrow anything. + return parentScope; + } + if (isLeaf(node)) { + const slugs = await runLeaf(node, parentScope, ctx); + if (node.negate) return diff(parentScope, slugs); + return intersect(parentScope, slugs); + } + // Group + if (node.op === "AND") { + let scope = parentScope; + for (const child of node.children) { + if (ctx.cancelled) break; + if (!isNodeActive(child)) continue; + scope = await orchestrate(child, scope, ctx); + } + if (node.negate) return diff(parentScope, scope); + return scope; + } + // OR + const childSlugSets: Set<string>[] = []; + await Promise.all( + node.children.map(async (child) => { + if (ctx.cancelled) return; + if (!isNodeActive(child)) return; + const r = await orchestrate(child, parentScope, ctx); + childSlugSets.push(r); + }), + ); + const unioned = unionAll(childSlugSets); + if (node.negate) return diff(parentScope, unioned); + return unioned; +} + +// Run a single leaf against the given scope. Checks cache; on miss, runs the +// appropriate scope-specific evaluator. Updates ctx.leafResults / leafStates +// and pushes a root re-evaluation via ctx.emitRoot on every progress event. +async function runLeaf( + leaf: LeafNode, + parentScope: Set<string>, + ctx: EvalCtx, +): Promise<Set<string>> { + if (!isLeafActive(leaf)) { + // Inactive (empty) leaf — record empty result, treat as identity. + setLeafState(ctx, leaf.id, { + slugCount: 0, + totalHits: 0, + processed: 0, + totalToProcess: 0, + capped: false, + cached: false, + active: false, + }); + ctx.leafResults.set(leaf.id, { slugs: new Set(), hits: new Map() }); + return parentScope; + } + + if (leaf.scope === "metadata") { + return runMetadataLeaf(leaf, parentScope, ctx); + } + + // Pre-filter chat-scope leaves to slugs that actually have a live_chat + // track. Saves wasted fetches and makes the cache scope hash stable + // against changes that only affect non-chat-having channels. + let effectiveScope = parentScope; + if (leaf.scope === "chat" && ctx.chatScopeSlugs) { + effectiveScope = intersect(parentScope, ctx.chatScopeSlugs); + } + + // Fully-network leaves (transcripts / chat). Try the layer cache first. + const scopeArr = Array.from(effectiveScope); + const scopeHash = hashSlugs(scopeArr); + const key = cacheKey(canonicalHash(leaf), scopeHash); + const cachedSync = getCachedSync(key); + if (cachedSync) { + return applyCached(leaf, parentScope, cachedSync, ctx, /*cached*/ true); + } + const cached = await getCached(key); + if (ctx.cancelled) return new Set(); + if (cached) { + return applyCached(leaf, parentScope, cached, ctx, /*cached*/ true); + } + + // Cache miss — kick off a real pipeline. Pure-filter leaves (no hit + // contribution) get an unbounded cap so a downstream AND chain can + // continue to narrow without partial-scope artifacts. + const initialHitLimit = leaf.contributeHits ? ctx.hitLimit : Infinity; + + setLeafState(ctx, leaf.id, { + slugCount: 0, + totalHits: 0, + processed: 0, + totalToProcess: scopeArr.length, + capped: false, + cached: false, + active: true, + }); + + let lastCapped = false; + const controller = runLeafPipeline({ + leaf, + scopeSlugs: scopeArr, + initialHitLimit, + concurrency: ctx.concurrency, + flushIntervalMs: ctx.flushIntervalMs, + emit: (p) => { + if (ctx.cancelled) return; + ctx.leafResults.set(leaf.id, { + slugs: new Set(p.slugs), + hits: new Map(p.hits), + }); + setLeafState(ctx, leaf.id, { + slugCount: p.slugs.size, + totalHits: p.totalHits, + processed: p.processed, + totalToProcess: p.totalToProcess, + capped: p.capped, + cached: false, + active: !p.done, + }); + lastCapped = p.capped; + ctx.emitRoot(); + }, + }); + ctx.controllers.set(leaf.id, controller); + const result = await controller.done; + ctx.controllers.delete(leaf.id); + if (ctx.cancelled) return new Set(); + // Persist final result only on a natural completion (not cap-limited and + // not cancelled). A capped result is partial — caching it would prevent a + // later "show more" from extending into the rest of the scope. + if (!lastCapped) { + putCached(key, { + slugs: new Set(result.slugs), + hits: new Map(result.hits), + }); + } + return new Set(result.slugs); +} + +function applyCached( + leaf: LeafNode, + parentScope: Set<string>, + cached: CachedResult, + ctx: EvalCtx, + isCacheHit: boolean, +): Set<string> { + const slugs = new Set<string>(); + for (const s of cached.slugs) slugs.add(s); + const hits = new Map<string, LayerHit[]>(); + for (const [slug, h] of cached.hits) hits.set(slug, h); + ctx.leafResults.set(leaf.id, { slugs, hits }); + setLeafState(ctx, leaf.id, { + slugCount: slugs.size, + totalHits: countHits(hits), + processed: parentScope.size, + totalToProcess: parentScope.size, + capped: false, + cached: isCacheHit, + active: false, + }); + ctx.emitRoot(); + return slugs; +} + +function runMetadataLeaf( + leaf: LeafNode, + parentScope: Set<string>, + ctx: EvalCtx, +): Set<string> { + const matched = new Set<string>(); + const hits = new Map<string, LayerHit[]>(); + const q = leaf.query.trim(); + if (!q) { + ctx.leafResults.set(leaf.id, { slugs: matched, hits }); + setLeafState(ctx, leaf.id, { + slugCount: 0, + totalHits: 0, + processed: parentScope.size, + totalToProcess: parentScope.size, + capped: false, + cached: false, + active: false, + }); + return parentScope; + } + let re: RegExp | null = null; + if (leaf.useRegex) { + try { + re = new RegExp(q, "i"); + } catch { + re = null; + } + if (!re) { + ctx.leafResults.set(leaf.id, { slugs: matched, hits }); + setLeafState(ctx, leaf.id, { + slugCount: 0, + totalHits: 0, + processed: parentScope.size, + totalToProcess: parentScope.size, + capped: false, + cached: false, + active: false, + }); + return new Set(); + } + } + const lower = re ? "" : q.toLowerCase(); + for (const slug of parentScope) { + const title = ctx.metadataIndex.titleLowerBySlug.get(slug); + const channel = ctx.metadataIndex.channelLowerBySlug.get(slug); + if (title === undefined && channel === undefined) continue; + const titleHit = re + ? title !== undefined && re.test(title) + : title !== undefined && title.includes(lower); + const channelHit = re + ? channel !== undefined && re.test(channel) + : channel !== undefined && channel.includes(lower); + if (!titleHit && !channelHit) continue; + matched.add(slug); + if (leaf.contributeHits) { + const summary = ctx.metadataIndex.bySlug.get(slug); + const out: LayerHit[] = []; + if (titleHit && summary) { + out.push({ + leafId: leaf.id, + scope: "metadata", + start: 0, + text: summary.title, + }); + } + if (channelHit && !titleHit && summary) { + // Channel-only hits don't have a meaningful timestamp; surface them + // with start=0 too. Result rendering treats start=0 as "no jump". + out.push({ + leafId: leaf.id, + scope: "metadata", + start: 0, + text: `Channel: ${summary.channel}`, + }); + } + if (out.length > 0) hits.set(slug, out); + } + } + ctx.leafResults.set(leaf.id, { slugs: matched, hits }); + setLeafState(ctx, leaf.id, { + slugCount: matched.size, + totalHits: countHits(hits), + processed: parentScope.size, + totalToProcess: parentScope.size, + capped: false, + cached: false, + active: false, + }); + ctx.emitRoot(); + // Negate handled by the caller in `orchestrate` — return raw matches. + return matched; +} + +// ─── Pure-tree re-evaluation ─── +// Computes the current root slug set + hit map from ctx.leafResults. Called +// on every leaf progress so the UI sees streaming narrowing. This is the +// "what does the tree currently produce" view, separate from the +// orchestration that actually fills leafResults. + +function evaluateTree( + node: QueryNode, + parentScope: Set<string>, + ctx: EvalCtx, +): { slugs: Set<string>; hits: Map<string, LayerHit[]> } { + const hits = new Map<string, LayerHit[]>(); + const slugs = evaluateSlugs(node, parentScope, ctx, hits); + // Filter hits to surviving slugs, so a leaf earlier in an AND chain + // doesn't leak hits for videos that a later child has since excluded. + for (const slug of Array.from(hits.keys())) { + if (!slugs.has(slug)) hits.delete(slug); + } + // Sort each video's hits by start time so transcripts + chat interleave + // chronologically across layers. + for (const list of hits.values()) list.sort((a, b) => a.start - b.start); + return { slugs, hits }; +} + +function evaluateSlugs( + node: QueryNode, + parentScope: Set<string>, + ctx: EvalCtx, + hitSink: Map<string, LayerHit[]>, +): Set<string> { + if (!isNodeActive(node)) return parentScope; + if (isLeaf(node)) { + const r = ctx.leafResults.get(node.id); + if (!r) { + // Pipeline hasn't started yet — be permissive so AND results don't + // briefly collapse to empty before the leaf has reported. + return parentScope; + } + if (node.contributeHits) { + for (const [slug, list] of r.hits) { + const existing = hitSink.get(slug); + if (existing) existing.push(...list); + else hitSink.set(slug, list.slice()); + } + } + if (node.negate) return diff(parentScope, r.slugs); + return intersect(parentScope, r.slugs); + } + // Group + if (node.op === "AND") { + let scope = parentScope; + for (const child of node.children) { + if (!isNodeActive(child)) continue; + scope = evaluateSlugs(child, scope, ctx, hitSink); + } + if (node.negate) return diff(parentScope, scope); + return scope; + } + // OR + const acc = new Set<string>(); + for (const child of node.children) { + if (!isNodeActive(child)) continue; + const r = evaluateSlugs(child, parentScope, ctx, hitSink); + for (const s of r) acc.add(s); + } + if (node.negate) return diff(parentScope, acc); + return acc; +} + +function collectGroupStates( + node: QueryNode, + parentScope: Set<string>, + ctx: EvalCtx, + out: Map<string, GroupState>, +): Set<string> { + if (!isNodeActive(node)) { + if (isGroup(node)) out.set(node.id, { slugCount: parentScope.size }); + return parentScope; + } + if (isLeaf(node)) { + const r = ctx.leafResults.get(node.id); + if (!r) return parentScope; + if (node.negate) return diff(parentScope, r.slugs); + return intersect(parentScope, r.slugs); + } + let scopeOut: Set<string>; + if (node.op === "AND") { + let scope = parentScope; + for (const child of node.children) { + if (!isNodeActive(child)) continue; + scope = collectGroupStates(child, scope, ctx, out); + } + scopeOut = scope; + } else { + const acc = new Set<string>(); + for (const child of node.children) { + if (!isNodeActive(child)) continue; + const r = collectGroupStates(child, parentScope, ctx, out); + for (const s of r) acc.add(s); + } + scopeOut = acc; + } + const finalScope = node.negate ? diff(parentScope, scopeOut) : scopeOut; + out.set(node.id, { slugCount: finalScope.size }); + return finalScope; +} + +function anyLeafCapped(ctx: EvalCtx): boolean { + for (const s of ctx.leafStates.values()) if (s.capped) return true; + return false; +} + +function setLeafState( + ctx: EvalCtx, + id: string, + state: LeafState, +): void { + ctx.leafStates.set(id, state); +} + +function countHits(hits: ReadonlyMap<string, LayerHit[]>): number { + let n = 0; + for (const list of hits.values()) n += list.length; + return n; +} + +function intersect(a: Set<string>, b: ReadonlySet<string>): Set<string> { + if (a.size === 0 || b.size === 0) return new Set(); + // Iterate the smaller set for cheaper membership lookups. + const [small, large]: [ReadonlySet<string>, ReadonlySet<string>] = + a.size <= b.size ? [a, b] : [b, a]; + const out = new Set<string>(); + for (const s of small) if (large.has(s)) out.add(s); + return out; +} + +function diff(a: Set<string>, b: ReadonlySet<string>): Set<string> { + const out = new Set<string>(); + for (const s of a) if (!b.has(s)) out.add(s); + return out; +} + +function unionAll(sets: Set<string>[]): Set<string> { + if (sets.length === 1) return new Set(sets[0]); + const out = new Set<string>(); + for (const s of sets) for (const v of s) out.add(v); + return out; +} + +// Re-export the leaf-scope type for UI consumers. +export type { MetadataLeafScope }; diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts @@ -0,0 +1,383 @@ +// Composite-search query tree. +// +// The tree is the user's full search expression: a root GroupNode (AND by +// default) containing LeafNodes (single search terms) and nested GroupNodes +// (sub-expressions). The eval engine in `searchEval.ts` walks this tree and +// returns the matching slug set + per-leaf hits. +// +// Serialization here is the source of truth for both the URL (`qt=` param) +// and IndexedDB-backed layer cache keys. Keep field names short — they end +// up in URLs. + +import type { SearchMode } from "../components/urlState"; + +export type LayerScope = "transcripts" | "chat" | "metadata"; + +export type GroupOp = "AND" | "OR"; + +export type LeafNode = { + kind: "leaf"; + id: string; + query: string; + scope: LayerScope; + useRegex: boolean; + contributeHits: boolean; + negate: boolean; +}; + +export type GroupNode = { + kind: "group"; + id: string; + op: GroupOp; + negate: boolean; + children: QueryNode[]; +}; + +export type QueryNode = LeafNode | GroupNode; + +let _idCounter = 0; +// IDs intentionally exclude Date.now() / Math.random() so that the same +// `emptyRoot()` call on the server and client produces matching IDs — Next's +// hydration warning fires loudly on any data-* attribute mismatch. +function nextId(prefix: string): string { + _idCounter = (_idCounter + 1) | 0; + return `${prefix}${_idCounter.toString(36)}`; +} + +export function newLeaf(partial: Partial<LeafNode> = {}): LeafNode { + return { + kind: "leaf", + id: partial.id ?? nextId("l"), + query: partial.query ?? "", + scope: partial.scope ?? "transcripts", + useRegex: partial.useRegex ?? false, + contributeHits: partial.contributeHits ?? true, + negate: partial.negate ?? false, + }; +} + +export function newGroup(partial: Partial<GroupNode> = {}): GroupNode { + return { + kind: "group", + id: partial.id ?? nextId("g"), + op: partial.op ?? "AND", + negate: partial.negate ?? false, + children: partial.children ?? [], + }; +} + +export function emptyRoot(): GroupNode { + return newGroup({ children: [newLeaf({ scope: "transcripts" })] }); +} + +export function isLeaf(n: QueryNode): n is LeafNode { + return n.kind === "leaf"; +} + +export function isGroup(n: QueryNode): n is GroupNode { + return n.kind === "group"; +} + +// A leaf is "active" (has any effect on evaluation) only when it has a +// non-empty query. Empty leaves are skipped at eval time rather than treated +// as a wildcard match — see searchEval.ts. +export function isLeafActive(leaf: LeafNode): boolean { + return leaf.query.trim().length > 0; +} + +// Recursively determine whether a node contributes anything to evaluation. +// Used to short-circuit empty subtrees so a freshly added blank leaf doesn't +// nuke an AND result down to zero. +export function isNodeActive(n: QueryNode): boolean { + if (isLeaf(n)) return isLeafActive(n); + return n.children.some(isNodeActive); +} + +// ─── Canonical hash ─── +// Stable hash over content (not IDs). Used as the prefix of the layer-cache +// key so semantically equivalent trees collide in cache regardless of the +// random IDs assigned to their nodes. Children of OR groups are sorted by +// their own hash; AND children stay ordered for evaluation purposes but the +// cache only consumes the final hash so order doesn't matter for hits. + +function fnv1a(str: string): string { + // 32-bit FNV-1a. Sufficient for cache keys (collision risk negligible at + // the cardinalities this UI produces) and avoids pulling in a crypto dep. + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + +export function canonicalHash(n: QueryNode): string { + return fnv1a(canonicalString(n)); +} + +function canonicalString(n: QueryNode): string { + if (isLeaf(n)) { + const q = n.useRegex ? n.query : n.query.toLowerCase(); + return `L|${n.scope}|${n.useRegex ? "r" : "p"}|${n.negate ? "n" : "y"}|${q}`; + } + const childStrs = n.children.filter(isNodeActive).map(canonicalString); + if (n.op === "OR") childStrs.sort(); + return `G|${n.op}|${n.negate ? "n" : "y"}|${childStrs.join("")}`; +} + +export function hashSlugs(slugs: ReadonlyArray<string> | ReadonlySet<string>): string { + const arr = Array.isArray(slugs) ? slugs.slice() : Array.from(slugs); + arr.sort(); + return fnv1a(arr.join("|")); +} + +// ─── Serialization (URL/storage) ─── +// Compact JSON shape: { o, c, n } for groups, { k:"l", q, s, r, h, n, i } for +// leaves. IDs are preserved so React keys stay stable across reloads. + +type SerializedLeaf = { + k: "l"; + i?: string; + q: string; + s: LayerScope; + r?: 1; + h?: 0; + n?: 1; +}; +type SerializedGroup = { + k: "g"; + i?: string; + o: GroupOp; + n?: 1; + c: Serialized[]; +}; +type Serialized = SerializedLeaf | SerializedGroup; + +function serialize(n: QueryNode): Serialized { + // IDs are intentionally NOT serialized — they're session-scoped (used as + // React keys + for the swatch colour hash). Persisting them would just + // produce noisy URLs without enabling anything useful, and leaving them + // out keeps the URL stable across sessions for the same logical query. + if (isLeaf(n)) { + const out: SerializedLeaf = { k: "l", q: n.query, s: n.scope }; + if (n.useRegex) out.r = 1; + if (!n.contributeHits) out.h = 0; + if (n.negate) out.n = 1; + return out; + } + const out: SerializedGroup = { + k: "g", + o: n.op, + c: n.children.map(serialize), + }; + if (n.negate) out.n = 1; + return out; +} + +function deserialize(raw: unknown): QueryNode | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (r.k === "l") { + const scope = r.s; + if (scope !== "transcripts" && scope !== "chat" && scope !== "metadata") { + return null; + } + return { + kind: "leaf", + id: typeof r.i === "string" && r.i ? r.i : nextId("l"), + query: typeof r.q === "string" ? r.q : "", + scope, + useRegex: r.r === 1, + contributeHits: r.h !== 0, + negate: r.n === 1, + }; + } + if (r.k === "g") { + const op = r.o; + if (op !== "AND" && op !== "OR") return null; + const rawChildren = Array.isArray(r.c) ? r.c : []; + const children: QueryNode[] = []; + for (const c of rawChildren) { + const child = deserialize(c); + if (child) children.push(child); + } + return { + kind: "group", + id: typeof r.i === "string" && r.i ? r.i : nextId("g"), + op, + negate: r.n === 1, + children, + }; + } + return null; +} + +export function stringifyRoot(root: GroupNode): string { + return JSON.stringify(serialize(root)); +} + +export function parseRoot(s: string): GroupNode | null { + let raw: unknown; + try { + raw = JSON.parse(s); + } catch { + return null; + } + const node = deserialize(raw); + if (!node || !isGroup(node)) return null; + return node; +} + +// Build a root from the legacy single-input search (`?q=` / `?m=` / `?re=`). +// Returns a one-leaf root targeted at the appropriate scope. Used when a URL +// has `q=` but no `qt=`, so old share links keep working. +export function rootFromLegacy( + q: string, + mode: SearchMode, + useRegex: boolean, +): GroupNode { + return newGroup({ + children: [ + newLeaf({ + query: q, + scope: mode === "subs" ? "chat" : "transcripts", + useRegex, + contributeHits: true, + }), + ], + }); +} + +// ─── Tree mutation helpers ─── +// All mutations are immutable: each helper returns a new root with the +// changed subtree replaced. Easier reasoning for React and matches the +// "edit-only-the-leaf" cache-reuse story (sibling subtrees keep identity). + +export function replaceNode( + root: GroupNode, + id: string, + next: QueryNode, +): GroupNode { + const out = replaceInNode(root, id, next); + if (!out || !isGroup(out)) return root; + return out; +} + +function replaceInNode( + node: QueryNode, + id: string, + next: QueryNode, +): QueryNode | null { + if (node.id === id) return next; + if (!isGroup(node)) return null; + let changed = false; + const newChildren: QueryNode[] = node.children.map((c) => { + const r = replaceInNode(c, id, next); + if (r) { + changed = true; + return r; + } + return c; + }); + if (!changed) return null; + return { ...node, children: newChildren }; +} + +export function removeNode(root: GroupNode, id: string): GroupNode { + const out = removeFromNode(root, id); + if (!out || !isGroup(out)) return root; + return out; +} + +function removeFromNode(node: QueryNode, id: string): QueryNode | null { + if (!isGroup(node)) return null; + let changed = false; + const newChildren: QueryNode[] = []; + for (const c of node.children) { + if (c.id === id) { + changed = true; + continue; + } + const r = removeFromNode(c, id); + if (r) { + changed = true; + newChildren.push(r); + } else { + newChildren.push(c); + } + } + if (!changed) return null; + return { ...node, children: newChildren }; +} + +export function insertChild( + root: GroupNode, + groupId: string, + child: QueryNode, + position: "start" | "end" = "end", +): GroupNode { + const out = insertInNode(root, groupId, child, position); + if (!out || !isGroup(out)) return root; + return out; +} + +function insertInNode( + node: QueryNode, + groupId: string, + child: QueryNode, + position: "start" | "end", +): QueryNode | null { + if (!isGroup(node)) return null; + if (node.id === groupId) { + const newChildren = + position === "start" + ? [child, ...node.children] + : [...node.children, child]; + return { ...node, children: newChildren }; + } + let changed = false; + const newChildren: QueryNode[] = node.children.map((c) => { + const r = insertInNode(c, groupId, child, position); + if (r) { + changed = true; + return r; + } + return c; + }); + if (!changed) return null; + return { ...node, children: newChildren }; +} + +// "Wrap leaf in a group": replace the leaf with a fresh AND group containing +// it. Used by the "Add OR sibling" UX so users don't have to manually create +// a group + drag the leaf into it. +export function wrapInGroup( + root: GroupNode, + id: string, + op: GroupOp = "AND", +): GroupNode { + let target: QueryNode | null = null; + function find(n: QueryNode): void { + if (n.id === id) { + target = n; + return; + } + if (isGroup(n)) for (const c of n.children) find(c); + } + find(root); + if (!target) return root; + return replaceNode(root, id, newGroup({ op, children: [target] })); +} + +// Walk every leaf in the tree, depth-first. Used by the UI to compute live +// counts, colour swatches, and the "contributing leaves" list. +export function forEachLeaf( + root: QueryNode, + fn: (leaf: LeafNode) => void, +): void { + if (isLeaf(root)) { + fn(root); + return; + } + for (const c of root.children) forEachLeaf(c, fn); +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Composable layered search (export viewer). The single search bar is now a query builder supporting AND / OR / NOT and arbitrary nesting; each layer targets transcripts, live chat, or title/channel. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Old `?q=…&m=…&re=…` share-links auto-migrate to a single-leaf tree. See `export/CHANGELOG.md` for the user-visible details. - New `/deploy` page that previews `export/CHANGELOG.md` pending changes, offers a "Cut release" form (turns `## [Unreleased]` into a dated semver heading) and a "Deploy" button (runs `pnpm run deploy` in `export/` with live log streaming). - "Cut release" form at the bottom of the `/changelog` page for the editor's own changelog. - New `/changelog` page that renders the editor's `CHANGELOG.md`. Linked from the sidebar nav. diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -3,5 +3,6 @@ ## [Unreleased] ### Added +- 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. - New `/changelog` page that renders the export's `CHANGELOG.md`. Linked from the right side of the sticky header. - Per-heading copy-link buttons on the changelog page for permalinks to any section. diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts @@ -0,0 +1,260 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + CHANNEL_SLUG, + VIDEO_CHAT_LARGE, + VIDEO_CHAT_SMALL, + VIDEO_TRANSCRIPT_ONLY, +} from "./fixtures/data"; +import { installRoutes } from "./helpers"; + +// Composite-search query tree e2e tests. +// +// Each test seeds the search via the `?qt=` URL parameter (compact JSON of +// the QueryNode tree) so we don't have to script the builder UI on every +// scenario. UI-interaction tests (cache-hit, URL round-trip from typing) +// still drive the builder directly. + +type SLeaf = { + k: "l"; + i?: string; + q: string; + s: "transcripts" | "chat" | "metadata"; + r?: 1; + h?: 0; + n?: 1; +}; +type SGroup = { + k: "g"; + i?: string; + o: "AND" | "OR"; + n?: 1; + c: (SLeaf | SGroup)[]; +}; + +function qt(root: SGroup): string { + return encodeURIComponent(JSON.stringify(root)); +} + +const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`; +const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`; +const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`; + +async function expectResultSlugs(page: Page, slugs: string[]) { + // Wait until the search has settled by waiting for the summary line to + // report at least the expected count. + const cards = page.locator("[data-result-slug]"); + await expect(async () => { + const got = await cards.evaluateAll((els) => + els.map((e) => e.getAttribute("data-result-slug") ?? ""), + ); + expect(got.slice().sort()).toEqual(slugs.slice().sort()); + }).toPass({ timeout: 15_000 }); +} + +test.describe("composite search — query tree", () => { + test.beforeEach(async ({ page }) => { + await installRoutes(page); + }); + + test("single leaf: transcripts:'alpha' matches all three fixture videos", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [{ k: "l", q: "alpha", s: "transcripts" }], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [ + TRANSCRIPT_ONLY_SLUG, + CHAT_SMALL_SLUG, + CHAT_LARGE_SLUG, + ]); + // Each result card carries a per-leaf hit section with the leaf's + // search term shown in monospace. + await expect( + page.locator("[data-leaf-section]").first(), + ).toBeVisible(); + }); + + test("AND narrows: transcripts:'small chat' AND chat:'message' → only VIDEO_CHAT_SMALL", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [ + { k: "l", q: "small chat", s: "transcripts" }, + { k: "l", q: "message", s: "chat" }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [CHAT_SMALL_SLUG]); + // Both leaves contributed hits — there should be two leaf-section bars. + const sections = page.locator( + `[data-result-slug="${CHAT_SMALL_SLUG}"] [data-leaf-section]`, + ); + await expect(sections).toHaveCount(2); + }); + + test("OR widens: chat:'message' OR chat:'#0' surfaces both chat videos", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [ + { + k: "g", + o: "OR", + c: [ + { k: "l", q: "message", s: "chat" }, + { k: "l", q: "(#0)", s: "chat" }, + ], + }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]); + }); + + test("NOT excludes: transcripts:'alpha' AND NOT chat:'message' → transcript_only + large_chat", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [ + { k: "l", q: "alpha", s: "transcripts" }, + { k: "l", q: "message", s: "chat", n: 1 }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + // VIDEO_CHAT_SMALL has "message" in chat — excluded. + // VIDEO_CHAT_LARGE has chat but no "message" word — kept. + // VIDEO_TRANSCRIPT_ONLY has no chat at all, so "not in chat:message" — kept. + await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG, CHAT_LARGE_SLUG]); + }); + + test("hit-contribution toggle: filter-only leaf does not surface its hits", async ({ + page, + }) => { + // chat leaf is a pure filter (h:0); only the transcripts leaf + // contributes hits. The matched video should have exactly one + // leaf-section (transcripts), not two. + const tree: SGroup = { + k: "g", + o: "AND", + c: [ + { k: "l", q: "small chat", s: "transcripts" }, + { k: "l", q: "message", s: "chat", h: 0 }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [CHAT_SMALL_SLUG]); + const sections = page.locator( + `[data-result-slug="${CHAT_SMALL_SLUG}"] [data-leaf-section]`, + ); + await expect(sections).toHaveCount(1); + }); + + test("metadata leaf: title-scope matches by title text", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [{ k: "l", q: "Transcript only", s: "metadata" }], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG]); + }); + + test("legacy URL: ?q=alpha auto-migrates to a single-leaf transcripts query", async ({ + page, + }) => { + await page.goto(`/?q=alpha`); + await expectResultSlugs(page, [ + TRANSCRIPT_ONLY_SLUG, + CHAT_SMALL_SLUG, + CHAT_LARGE_SLUG, + ]); + }); + + test("legacy URL: ?q=message&m=subs migrates to a single-leaf chat query", async ({ + page, + }) => { + await page.goto(`/?q=message&m=subs`); + await expectResultSlugs(page, [CHAT_SMALL_SLUG]); + }); + + test("URL round-trip: builder reflects qt= tree on load and Share writes qt", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [ + { k: "l", q: "small chat", s: "transcripts" }, + { k: "l", q: "message", s: "chat" }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [CHAT_SMALL_SLUG]); + // Two leaf inputs are rendered in the builder. + const leafInputs = page.locator('input[data-testid^="leaf-query-"]'); + await expect(leafInputs).toHaveCount(2); + await expect(leafInputs.nth(0)).toHaveValue("small chat"); + await expect(leafInputs.nth(1)).toHaveValue("message"); + }); + + test("cache: editing one leaf re-runs only that leaf; sibling shows 'cached' badge", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [ + { k: "l", q: "small chat", s: "transcripts" }, + { k: "l", q: "message", s: "chat" }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [CHAT_SMALL_SLUG]); + + // Edit the chat leaf's query, then re-submit. The transcripts leaf's + // (canonical-hash, scope-hash) is unchanged, so it hits the layer cache + // and renders the `cached` badge. + const leafInputs = page.locator('input[data-testid^="leaf-query-"]'); + const chatInput = leafInputs.nth(1); + await chatInput.fill("@user0"); + await page.getByTestId("search-submit").click(); + // After the re-run settles, the transcript-only leaf should be cached. + await expect(page.locator("[data-cache-hit]").first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test("builder UI: + Add layer adds a second leaf and commits on Search", async ({ + page, + }) => { + await page.goto("/"); + // Start in compact mode: one leaf, "+ Add layer" pill below. + const compactAdd = page.getByTestId("compact-add-layer"); + await expect(compactAdd).toBeVisible(); + // Fill the first leaf via its query input. + const firstInput = page.locator('input[data-testid^="leaf-query-"]').first(); + await firstInput.fill("small chat"); + await compactAdd.click(); + // Now there should be two leaves (the builder switched to the full + // group rendering). + const inputs = page.locator('input[data-testid^="leaf-query-"]'); + await expect(inputs).toHaveCount(2); + await inputs.nth(1).fill("message"); + // The new leaf defaults to scope=transcripts; switch it to chat. + const scopeSelects = page.locator('select[data-testid^="leaf-scope-"]'); + await scopeSelects.nth(1).selectOption("chat"); + await page.getByTestId("search-submit").click(); + await expectResultSlugs(page, [CHAT_SMALL_SLUG]); + }); +}); diff --git a/export/e2e/share-current-search.spec.ts b/export/e2e/share-current-search.spec.ts @@ -29,8 +29,15 @@ test.describe("search page — share current search", () => { await search.fill("alpha"); await search.press("Enter"); - // After commit the URL only carries q (filters stripped). - await expect(page).toHaveURL(/\?q=alpha$/); + // After commit the URL carries `qt=` (the composite-search tree) and + // filter params are stripped. Legacy `q=` is cleared. + await expect(page).toHaveURL(/[?&]qt=/); + const after = await urlParams(page); + expect(after.get("q")).toBeNull(); + expect(after.get("qt")).not.toBeNull(); + const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? "")); + 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(); @@ -40,7 +47,11 @@ test.describe("search page — share current search", () => { 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"); + // Composite query is encoded in `qt=`; legacy `q=` is omitted. + expect(url.searchParams.get("q")).toBeNull(); + expect(url.searchParams.get("qt")).not.toBeNull(); + const sharedTree = JSON.parse(url.searchParams.get("qt") ?? ""); + expect(sharedTree.c[0].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. @@ -84,12 +95,16 @@ test.describe("search page — share current search", () => { // Trigger another commit to confirm strip-on-commit behavior. await page.getByPlaceholder("Search transcripts...").press("Enter"); - await expect(page).toHaveURL(/\?q=alpha$/); + await expect(page).toHaveURL(/[?&]qt=/); 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(); + // Legacy `q=` is migrated to a single-leaf tree under `qt=`. + expect(after.get("q")).toBeNull(); + const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? "")); + expect(tree.c[0].q).toBe("alpha"); }); test("legacy link still applies filters and is stripped on next commit", async ({ @@ -109,11 +124,15 @@ test.describe("search page — share current search", () => { page.getByRole("checkbox", { name: "Livestreams" }), ).toBeChecked(); - // Re-commit; legacy params are stripped. + // Re-commit; legacy filter params are stripped, and `q=` migrates to + // a single-leaf tree under `qt=`. await page.getByPlaceholder("Search transcripts...").press("Enter"); - await expect(page).toHaveURL(/\?q=hello$/); + await expect(page).toHaveURL(/[?&]qt=/); const after = await urlParams(page); expect(after.get("nov")).toBeNull(); expect(after.get("nd")).toBeNull(); + expect(after.get("q")).toBeNull(); + const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? "")); + expect(tree.c[0].q).toBe("hello"); }); });