Archilyzer · Source

archilyzer

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

commit e3ad73c39008087320759663220da1b4eb17405a
parent 53781260c6509097fd6ece02e4397525fedfeab6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 30 Jun 2026 21:48:26 -0400

Add FlexSearch web-worker index: off-main-thread build + instant offline search

Integrate FlexSearch with a custom Web Worker so index building/loading is
async (off the main thread), and surface it as instant offline word-search on
the export sites — composing with the just-added PWA offline shard cache.

Why a custom worker (not FlexSearch's built-in worker:true): the ESM build Next
resolves doesn't set _factory (no inline-blob worker) and its import.meta.url
worker-path guess breaks under Turbopack asset hashing. A worker we own
(import { Index } from "flexsearch" + postMessage) is bundler-friendly and — as
verified here — bundles into the static export.

- common/components/searchIndex.worker.ts — the worker: holds a per-channel
  FlexSearch Index, builds it from the /transcripts/<slug> shards (the same ones
  the offline SW caches, so an offline channel indexes with no network), and
  answers searches; forward tokenizer + LatinBalance for prefix/word recall.
- common/components/searchIndexWorkerClient.ts — main-thread client: lazily
  spawns one module worker via new Worker(new URL('./searchIndex.worker.ts',
  import.meta.url)) (the form Turbopack statically bundles), with an async
  request/response API (build/search/status/drop) + build progress. Structured
  to later back the main TranscriptSearch.
- searchIndexWorkerProtocol.ts — shared message types.
- export OfflineManager: per-channel "Build index" (live % progress) + a search
  box that queries all indexed channels through the worker and lists matching
  transcript lines (channel + timestamp).
- common: + flexsearch ^0.8.0 (resolves 0.8.212).

Verified: common + export typecheck clean; a real output:export build EMITS the
worker (out/_next/static/media/searchIndex.worker.*.ts + the turbopack-worker
bootstrap + flexsearch chunks) — proving the stack works under static hosting;
new e2e pwa-search.spec.ts builds an index in the worker and searches it (real
browser); existing pwa + browse-all search e2e still green. CHANGELOG updated.

Scope note: this is a self-contained worker+offline slice. Wiring the worker
index into the redesigned main TranscriptSearch query path (merging the larger
flexsearch spike's searchEval seam) is the scoped follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Acommon/components/searchIndex.worker.ts | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/searchIndexWorkerClient.ts | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/searchIndexWorkerProtocol.ts | 21+++++++++++++++++++++
Mcommon/package.json | 1+
Mexport/CHANGELOG.md | 1+
Mexport/app/components/OfflineManager.tsx | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Aexport/e2e/pwa-search.spec.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 8++++++++
8 files changed, 550 insertions(+), 22 deletions(-)

diff --git a/common/components/searchIndex.worker.ts b/common/components/searchIndex.worker.ts @@ -0,0 +1,147 @@ +// FlexSearch index web worker. Builds and queries a per-channel full-text index +// entirely off the main thread, so indexing hundreds of MB of transcript text +// never blocks the UI. Bundled by Turbopack under output:"export" (proven to +// emit into static out/) via `new Worker(new URL('./searchIndex.worker.ts', +// import.meta.url), {type:'module'})` in searchIndexWorkerClient. +// +// The worker fetches the same /transcripts/<slug>/{manifest,page-NNNN}.json +// shards the scanner uses — so a service-worker-cached (offline) channel indexes +// and searches with no network. + +import { Index, Charset } from "flexsearch"; +import type { + IndexHit, + WorkerRequest, + WorkerResponse, +} from "./searchIndexWorkerProtocol"; + +// Minimal worker-global surface (avoids depending on the webworker tsconfig lib). +const ctx = globalThis as unknown as { + postMessage(msg: WorkerResponse): void; + addEventListener( + type: "message", + cb: (e: MessageEvent<WorkerRequest>) => void, + ): void; +}; + +type ChannelIndex = { + idx: Index; + // docId → the hit it resolves to (FlexSearch returns ids, not the source). + meta: IndexHit[]; +}; + +const channels = new Map<string, ChannelIndex>(); + +function post(msg: WorkerResponse): void { + ctx.postMessage(msg); +} + +function pageFileName(index: number): string { + return `page-${String(index).padStart(4, "0")}.json`; +} + +function mkIndex(): Index { + // forward tokenizer → prefix matches (search-as-you-type); LatinBalance folds + // case/diacritics for recall without the heavier phonetic passes. + return new Index({ + tokenize: "forward", + encoder: Charset.LatinBalance, + resolution: 9, + } as never); +} + +type RawCue = { start?: number; text?: unknown }; +type RawEntry = { slug?: unknown; cues?: unknown }; + +async function build(reqId: number, slug: string): Promise<void> { + // Manifest → page count. + const manRes = await fetch(`/transcripts/${slug}/manifest.json`, { + cache: "force-cache", + }); + if (!manRes.ok) throw new Error(`manifest ${manRes.status}`); + const manifest = (await manRes.json()) as { pageCount?: number }; + const pageCount = manifest.pageCount ?? 0; + + const idx = mkIndex(); + const meta: IndexHit[] = []; + let id = 0; + + for (let p = 0; p < pageCount; p++) { + const res = await fetch(`/transcripts/${slug}/${pageFileName(p)}`, { + cache: "force-cache", + }); + if (!res.ok) throw new Error(`page ${p} ${res.status}`); + const entries = (await res.json()) as RawEntry[]; + for (const entry of entries) { + const eslug = typeof entry.slug === "string" ? entry.slug : slug; + const cues = Array.isArray(entry.cues) ? entry.cues : []; + for (const c of cues as RawCue[]) { + const text = typeof c.text === "string" ? c.text : ""; + if (!text) continue; + idx.add(id, text); + meta[id] = { slug: eslug, start: c.start, text }; + id++; + } + } + post({ type: "progress", reqId, slug, done: p + 1, total: pageCount }); + } + + channels.set(slug, { idx, meta }); + post({ type: "built", reqId, slug, docCount: meta.length }); +} + +async function search( + reqId: number, + slug: string, + term: string, + limit: number, +): Promise<void> { + const ch = channels.get(slug); + if (!ch) { + post({ type: "result", reqId, slug, hits: [] }); + return; + } + // The in-worker Index is synchronous; search returns ids directly. + const ids = ch.idx.search(term, { limit }) as unknown as number[]; + const hits: IndexHit[] = []; + for (const rid of ids) { + const m = ch.meta[rid]; + if (m) hits.push(m); + } + post({ type: "result", reqId, slug, hits }); +} + +ctx.addEventListener("message", (e: MessageEvent<WorkerRequest>) => { + const msg = e.data; + const fail = (err: unknown) => + post({ + type: "error", + reqId: msg.reqId, + slug: (msg as { slug?: string }).slug ?? "", + message: err instanceof Error ? err.message : String(err), + }); + + switch (msg.type) { + case "build": + build(msg.reqId, msg.slug).catch(fail); + break; + case "search": + search(msg.reqId, msg.slug, msg.term, msg.limit).catch(fail); + break; + case "status": { + const ch = channels.get(msg.slug); + post({ + type: "status", + reqId: msg.reqId, + slug: msg.slug, + ready: !!ch, + docCount: ch ? ch.meta.length : 0, + }); + break; + } + case "drop": + channels.delete(msg.slug); + post({ type: "dropped", reqId: msg.reqId, slug: msg.slug }); + break; + } +}); diff --git a/common/components/searchIndexWorkerClient.ts b/common/components/searchIndexWorkerClient.ts @@ -0,0 +1,100 @@ +"use client"; + +// Main-thread client for the FlexSearch web worker. Lazily spawns one module +// worker and exposes an async request/response API (build / search / status / +// drop) with build progress. The `new Worker(new URL(...))` form is what +// Turbopack statically detects to bundle the worker into the static export. + +import type { + IndexHit, + WorkerRequest, + WorkerResponse, +} from "./searchIndexWorkerProtocol"; + +export type { IndexHit } from "./searchIndexWorkerProtocol"; + +// Distributive Omit so the discriminated-union request keeps its per-variant +// fields (a plain Omit<Union, K> collapses to the common keys only). +type DistributiveOmit<T, K extends keyof any> = T extends unknown + ? Omit<T, K> + : never; + +type Pending = { + resolve: (value: WorkerResponse) => void; + reject: (err: Error) => void; + onProgress?: (done: number, total: number) => void; +}; + +let worker: Worker | null = null; +let nextReqId = 1; +const pending = new Map<number, Pending>(); + +export function workerSupported(): boolean { + return typeof window !== "undefined" && typeof Worker !== "undefined"; +} + +function getWorker(): Worker | null { + if (!workerSupported()) return null; + if (worker) return worker; + worker = new Worker(new URL("./searchIndex.worker.ts", import.meta.url), { + type: "module", + }); + worker.addEventListener("message", (e: MessageEvent<WorkerResponse>) => { + const msg = e.data; + const p = pending.get(msg.reqId); + if (!p) return; + if (msg.type === "progress") { + p.onProgress?.(msg.done, msg.total); + return; // keep pending open until the terminal reply + } + pending.delete(msg.reqId); + if (msg.type === "error") p.reject(new Error(msg.message)); + else p.resolve(msg); + }); + return worker; +} + +function request( + req: DistributiveOmit<WorkerRequest, "reqId">, + onProgress?: (done: number, total: number) => void, +): Promise<WorkerResponse> { + const w = getWorker(); + if (!w) return Promise.reject(new Error("Web workers unavailable")); + const reqId = nextReqId++; + return new Promise<WorkerResponse>((resolve, reject) => { + pending.set(reqId, { resolve, reject, onProgress }); + w.postMessage({ ...req, reqId } as WorkerRequest); + }); +} + +// Build (or rebuild) a channel's index in the worker from its transcript shards. +// Resolves with the indexed document (cue) count. +export async function buildChannelIndex( + slug: string, + onProgress?: (done: number, total: number) => void, +): Promise<number> { + const res = await request({ type: "build", slug }, onProgress); + return res.type === "built" ? res.docCount : 0; +} + +export async function searchChannel( + slug: string, + term: string, + limit = 50, +): Promise<IndexHit[]> { + const res = await request({ type: "search", slug, term, limit }); + return res.type === "result" ? res.hits : []; +} + +export async function channelIndexStatus( + slug: string, +): Promise<{ ready: boolean; docCount: number }> { + const res = await request({ type: "status", slug }); + return res.type === "status" + ? { ready: res.ready, docCount: res.docCount } + : { ready: false, docCount: 0 }; +} + +export async function dropChannelIndex(slug: string): Promise<void> { + await request({ type: "drop", slug }); +} diff --git a/common/components/searchIndexWorkerProtocol.ts b/common/components/searchIndexWorkerProtocol.ts @@ -0,0 +1,21 @@ +// Message protocol shared between the main thread (searchIndexWorkerClient) and +// the FlexSearch web worker (searchIndex.worker). Types only — safe to import +// from both sides. + +export type IndexHit = { slug: string; start?: number; text: string }; + +// Main thread → worker. +export type WorkerRequest = + | { type: "build"; reqId: number; slug: string } + | { type: "search"; reqId: number; slug: string; term: string; limit: number } + | { type: "status"; reqId: number; slug: string } + | { type: "drop"; reqId: number; slug: string }; + +// Worker → main thread. +export type WorkerResponse = + | { type: "progress"; reqId: number; slug: string; done: number; total: number } + | { type: "built"; reqId: number; slug: string; docCount: number } + | { type: "result"; reqId: number; slug: string; hits: IndexHit[] } + | { type: "status"; reqId: number; slug: string; ready: boolean; docCount: number } + | { type: "dropped"; reqId: number; slug: string } + | { type: "error"; reqId: number; slug: string; message: string }; diff --git a/common/package.json b/common/package.json @@ -12,6 +12,7 @@ "cmdk": "^1.1.1", "d3-scale": "^4.0.2", "execa": "^9.6.1", + "flexsearch": "^0.8.0", "fs-extra": "^11.3.4", "lmdb": "^3.5.4", "lucide-react": "^1.16.0", diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Instant offline word-search, powered by a background FlexSearch index.** On the Offline page, each channel now has a **Build index** action: it constructs a FlexSearch full-text index for that channel in a **Web Worker** — entirely off the main thread, so indexing a large channel never freezes the page (a live progress % shows the work). Once built, a search box does **instant word/prefix search** across your indexed channels and returns the matching transcript lines (with channel + timestamp) — and because the index is built from the same shards the offline download caches, it works with no connection. The worker bundles into the static site (no server needed). This is a first, self-contained surface; the main search box will gain the same acceleration next. - **Install the archive as an app, and search it offline.** Each public site is now a PWA: it ships a web app manifest (named + themed with the site's accent color) and an icon set, so your browser can install it to your home screen or desktop and open it in its own window. A service worker caches the app so it loads instantly on repeat visits and still opens without a connection. A new **Offline** page (linked in the footer) lists the site's channels with a **Download** button each — pick the channels you want and their transcripts are saved to your device so you can **search them with no connection**; a live Online/Offline indicator and per-channel "Available offline" state show what's ready, and you can remove a channel to free space. Downloads are opt-in (a channel's transcripts can be large) and refresh automatically when the site is rebuilt. Everything still works online-only in browsers without service-worker support. - **Two more themes + a theme picker.** A palette menu next to the light/dark toggle lets you switch the whole site's look between **Archive** (the warm reading room), **Terminal** (an ice-cyan signal terminal), **Swiss** (red/black/white editorial), and **Base** (neutral) — each in light or dark, and your choice is remembered. Switching is instant and purely cosmetic (just colors; the layout never changes). - **Optional per-site brand accent.** A site can set its own accent color (a hex, in the editor's site config) to replace the family brass everywhere on its public build — the wordmark mark, links, badges, and highlights. It's baked onto the page at build time so it's correct on the very first paint, and applies in both light and dark. Sites that don't set one keep the family brass. diff --git a/export/app/components/OfflineManager.tsx b/export/app/components/OfflineManager.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { channelCachedPages, downloadChannelOffline, @@ -8,13 +8,35 @@ import { isPinned, swSupported, } from "../lib/offlineCache"; +import { + buildChannelIndex, + dropChannelIndex, + searchChannel, + workerSupported, + type IndexHit, +} from "yt-dlp-transcript-common/components/searchIndexWorkerClient"; export type OfflineChannel = { slug: string; name: string }; type ChannelState = { - status: "idle" | "downloading" | "cached" | "error"; + download: "idle" | "downloading" | "cached" | "error"; done: number; total: number; + // Worker FlexSearch index state. + index: "none" | "building" | "ready" | "error"; + idxDone: number; + idxTotal: number; + docCount: number; +}; + +const DEFAULT: ChannelState = { + download: "idle", + done: 0, + total: 0, + index: "none", + idxDone: 0, + idxTotal: 0, + docCount: 0, }; function useOnline(): boolean { @@ -35,32 +57,29 @@ function useOnline(): boolean { export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { const online = useOnline(); const [supported, setSupported] = useState(true); + const [hasWorker, setHasWorker] = useState(false); const [states, setStates] = useState<Record<string, ChannelState>>({}); const setState = useCallback((slug: string, patch: Partial<ChannelState>) => { setStates((prev) => { - const base: ChannelState = prev[slug] ?? { - status: "idle", - done: 0, - total: 0, - }; + const base = prev[slug] ?? DEFAULT; return { ...prev, [slug]: { ...base, ...patch } }; }); }, []); - // On mount: detect SW support and mark already-cached channels. useEffect(() => { if (!swSupported()) { setSupported(false); return; } + setHasWorker(workerSupported()); let cancelled = false; (async () => { for (const c of channels) { if (!isPinned(c.slug)) continue; const cached = await channelCachedPages(c.slug); if (!cancelled && cached > 0) { - setState(c.slug, { status: "cached", done: cached, total: cached }); + setState(c.slug, { download: "cached", done: cached, total: cached }); } } })(); @@ -70,18 +89,44 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { }, [channels, setState]); async function download(slug: string) { - setState(slug, { status: "downloading", done: 0, total: 0 }); + setState(slug, { download: "downloading", done: 0, total: 0 }); const ok = await downloadChannelOffline(slug, ({ done, total }) => - setState(slug, { status: "downloading", done, total }), + setState(slug, { download: "downloading", done, total }), ).catch(() => false); - setState(slug, ok ? { status: "cached" } : { status: "error" }); + setState(slug, { download: ok ? "cached" : "error" }); } async function remove(slug: string) { await evictChannelOffline(slug); - setState(slug, { status: "idle", done: 0, total: 0 }); + await dropChannelIndex(slug).catch(() => {}); + setState(slug, { ...DEFAULT }); + } + + async function buildIndex(slug: string) { + setState(slug, { index: "building", idxDone: 0, idxTotal: 0 }); + try { + const docCount = await buildChannelIndex(slug, (done, total) => + setState(slug, { index: "building", idxDone: done, idxTotal: total }), + ); + setState(slug, { index: "ready", docCount }); + } catch { + setState(slug, { index: "error" }); + } } + const indexedSlugs = useMemo( + () => + channels + .filter((c) => states[c.slug]?.index === "ready") + .map((c) => c.slug), + [channels, states], + ); + const nameBySlug = useMemo(() => { + const m = new Map<string, string>(); + for (const c of channels) m.set(c.slug, c.name); + return m; + }, [channels]); + if (!supported) { return ( <div @@ -95,7 +140,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { } return ( - <div className="flex flex-col gap-3"> + <div className="flex flex-col gap-4"> <div role="status" aria-label="connection status" @@ -111,6 +156,10 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { {online ? "Online" : "Offline"} </div> + {hasWorker && indexedSlugs.length > 0 && ( + <OfflineSearch indexedSlugs={indexedSlugs} nameBySlug={nameBySlug} /> + )} + {channels.length === 0 ? ( <p className="text-sm text-muted-foreground"> This site has no channels. @@ -118,7 +167,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { ) : ( <ul className="flex flex-col divide-y divide-border rounded-lg border border-border"> {channels.map((c) => { - const st = states[c.slug] ?? { status: "idle", done: 0, total: 0 }; + const st = states[c.slug] ?? DEFAULT; return ( <li key={c.slug} @@ -129,7 +178,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { {c.name} </span> - {st.status === "cached" && ( + {st.download === "cached" && ( <span aria-label={`${c.slug} available offline`} className="rounded-full border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs text-brand" @@ -137,20 +186,51 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { Available offline </span> )} - {st.status === "error" && ( + {st.index === "ready" && ( + <span + aria-label={`${c.slug} indexed`} + className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs text-muted-foreground" + > + Indexed · {st.docCount.toLocaleString()} + </span> + )} + {(st.download === "error" || st.index === "error") && ( <span className="text-xs text-destructive"> - Download failed + {st.index === "error" ? "Index failed" : "Download failed"} </span> )} - {st.status === "downloading" && ( + {st.download === "downloading" && ( <span className="font-mono text-xs tabular-nums text-muted-foreground"> {st.total > 0 ? `${Math.round((st.done / st.total) * 100)}%` : "starting…"} </span> )} + {st.index === "building" && ( + <span className="font-mono text-xs tabular-nums text-muted-foreground"> + indexing{" "} + {st.idxTotal > 0 + ? `${Math.round((st.idxDone / st.idxTotal) * 100)}%` + : "…"} + </span> + )} + + {hasWorker && st.index !== "ready" && ( + <button + type="button" + disabled={st.index === "building" || !online} + onClick={() => buildIndex(c.slug)} + aria-label={`build search index for ${c.slug}`} + title={ + !online ? "Reconnect to build the index" : undefined + } + className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted disabled:opacity-50" + > + {st.index === "building" ? "Indexing…" : "Build index"} + </button> + )} - {st.status === "cached" ? ( + {st.download === "cached" ? ( <button type="button" onClick={() => remove(c.slug)} @@ -162,13 +242,13 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { ) : ( <button type="button" - disabled={st.status === "downloading" || !online} + disabled={st.download === "downloading" || !online} onClick={() => download(c.slug)} aria-label={`download ${c.slug} for offline`} title={!online ? "Reconnect to download" : undefined} className="rounded-md border border-brand/30 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand transition-colors hover:bg-brand/15 disabled:opacity-50" > - {st.status === "downloading" ? "Downloading…" : "Download"} + {st.download === "downloading" ? "Downloading…" : "Download"} </button> )} </li> @@ -179,3 +259,97 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { </div> ); } + +// Instant word-search across the channels whose FlexSearch index has been built +// in the worker. Runs entirely off the main thread and works offline once the +// channel's shards are cached. +function OfflineSearch({ + indexedSlugs, + nameBySlug, +}: { + indexedSlugs: string[]; + nameBySlug: Map<string, string>; +}) { + const [term, setTerm] = useState(""); + const [hits, setHits] = useState<{ channel: string; hit: IndexHit }[]>([]); + const [searching, setSearching] = useState(false); + const runId = useRef(0); + + useEffect(() => { + const q = term.trim(); + if (q.length < 2) { + setHits([]); + return; + } + const id = ++runId.current; + setSearching(true); + const timer = setTimeout(async () => { + const perChannel = await Promise.all( + indexedSlugs.map((slug) => + searchChannel(slug, q, 20) + .then((hs) => hs.map((hit) => ({ channel: slug, hit }))) + .catch(() => []), + ), + ); + if (id !== runId.current) return; // a newer query superseded this one + setHits(perChannel.flat().slice(0, 40)); + setSearching(false); + }, 180); + return () => clearTimeout(timer); + }, [term, indexedSlugs]); + + return ( + <div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4"> + <label className="flex flex-col gap-1"> + <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground"> + Offline search · {indexedSlugs.length} channel + {indexedSlugs.length === 1 ? "" : "s"} indexed + </span> + <input + type="search" + value={term} + onChange={(e) => setTerm(e.target.value)} + aria-label="offline search" + placeholder="Search indexed transcripts…" + className="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + </label> + {term.trim().length >= 2 && ( + <div + aria-label="offline search results" + className="flex flex-col gap-1 text-sm" + > + {searching && hits.length === 0 ? ( + <p className="text-muted-foreground">Searching…</p> + ) : hits.length === 0 ? ( + <p className="text-muted-foreground">No matches.</p> + ) : ( + <ul className="flex flex-col gap-1"> + {hits.map(({ channel, hit }, i) => ( + <li + key={`${channel}-${hit.slug}-${hit.start ?? i}-${i}`} + className="flex flex-col rounded-md px-2 py-1 hover:bg-muted" + > + <span className="truncate text-foreground">{hit.text}</span> + <span className="font-mono text-[11px] text-muted-foreground"> + {nameBySlug.get(channel) ?? channel} · {hit.slug} + {typeof hit.start === "number" + ? ` · ${formatTime(hit.start)}` + : ""} + </span> + </li> + ))} + </ul> + )} + </div> + )} + </div> + ); +} + +function formatTime(sec: number): string { + const s = Math.max(0, Math.floor(sec)); + const m = Math.floor(s / 60); + const r = s % 60; + return `${m}:${String(r).padStart(2, "0")}`; +} diff --git a/export/e2e/pwa-search.spec.ts b/export/e2e/pwa-search.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from "@playwright/test"; + +// Exercises the FlexSearch web worker end to end: /offline builds a channel's +// index off the main thread from the transcript shards, then searches it. The +// SW is disabled in dev (production-only), so the worker's shard fetches hit the +// network and are mocked here via context.route (which reaches worker requests). + +const SLUG = "test-youtube"; // testsite's channel (see e2e/fixtures/sites) + +test("builds a channel index in the worker and searches it offline", async ({ + page, +}) => { + await page.context().route( + `**/transcripts/${SLUG}/manifest.json`, + async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + version: 2, + channelSlug: SLUG, + pageCount: 1, + maxPageBytes: 1000, + generatedAt: "2026-06-30T00:00:00.000Z", + slugToPage: {}, + }), + }); + }, + ); + await page.context().route( + `**/transcripts/${SLUG}/page-0000.json`, + async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify([ + { + slug: "vidA", + cues: [ + { start: 0, text: "a constitutional hearing began today" }, + { start: 12, text: "the witness testified under oath" }, + ], + }, + { + slug: "vidB", + cues: [{ start: 3, text: "an unrelated cooking segment" }], + }, + ]), + }); + }, + ); + + await page.goto("/offline/"); + + const build = page.getByRole("button", { + name: `build search index for ${SLUG}`, + }); + await expect(build).toBeVisible(); + await build.click(); + + // The worker finishes → an "Indexed · N" badge appears. + await expect(page.getByLabel(`${SLUG} indexed`)).toBeVisible({ + timeout: 20_000, + }); + + // The offline search box is now available; a word query returns the matching + // cue via the worker's FlexSearch index. + const box = page.getByLabel("offline search", { exact: true }); + await expect(box).toBeVisible(); + await box.fill("constitutional"); + const results = page.getByLabel("offline search results"); + await expect(results).toContainText("constitutional hearing", { + timeout: 10_000, + }); + // A non-matching term yields no hit for that cue. + await box.fill("cooking"); + await expect(results).toContainText("cooking segment", { timeout: 10_000 }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 + flexsearch: + specifier: ^0.8.0 + version: 0.8.212 fs-extra: specifier: ^11.3.4 version: 11.3.4 @@ -2552,6 +2555,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flexsearch@0.8.212: + resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -6195,6 +6201,8 @@ snapshots: flatted@3.4.2: {} + flexsearch@0.8.212: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7