Archilyzer · Source

archilyzer

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

commit d009376e0169c908958531a7d0017f226d1ed258
parent 04eff9b768dec55db6fbcc8c46acee6b4b2c4b96
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 13:10:41 -0400

tags S2.8: draftTags / committedTags in the search session

The chip selection is an ordinary filter and behaves like one: a draft set
the panel edits, a committed set the results are filtered by, promoted on
Search. passesFilter gains the same OR the MCP and the record evaluator
apply, and filterKey gains the sorted selection so re-clicking the same two
chips in the other order does not re-run the pipeline.

Posts are excluded while a tag filter is on, in both the scope build and the
matched-post fold. A post carries no curated tags, so letting the posts
corpus through unfiltered would have made "streams where Eva collabs" quietly
also mean "and every tweet".

FilterSnapshot carries `tg`, omitted when empty so a profile saved on an
untagged site is byte-identical to one saved before tags existed.

The URL: `tg` is written live from the committed selection by its own effect
(post-hydration only — writing before reading would erase the selection the
link arrived with), read back first and independently of the share-v1 and
legacy branches, and appended to a copied share link by hand. That is three
small places instead of teaching the share schema and the strip-on-commit
helper about a key that belongs to neither, and it is what makes the param
round-trip through a reload on its own.

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

Diffstat:
Mcommon/components/SearchSessionContext.tsx | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/components/exportFilterStorage.ts | 6++++++
2 files changed, 108 insertions(+), 3 deletions(-)

diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx @@ -149,6 +149,11 @@ export type ResultGroup = { platform: Platform; uploadDate: string; hits: LayerHit[]; + // The operator's curated tag ids on this record (lib/curatedTags.ts), when it + // carries any — so a card can show what it was tagged as. Absent on posts, on + // untagged videos, and on every record from a site built before corpus spec + // 4. Labels come from the site's /tags.json; the id is the fallback. + curatedTags?: string[]; // Provenance accent of the source origin (hub mode only); undefined // single-site, so no marker renders. accent?: string; @@ -365,6 +370,12 @@ function useSearchSessionState() { // Inclusive upload-date bounds ("YYYYMMDD"); "" => unbounded on that end. const [committedDateFrom, setCommittedDateFrom] = useState(""); const [committedDateTo, setCommittedDateTo] = useState(""); + // Curated tag ids selected in the chip row, ORed: a video carrying ANY of + // them passes. An EMPTY set is no filter at all — the row starts empty, and + // on a site that publishes no /tags.json it is empty forever. + const [committedTags, setCommittedTags] = useState<ReadonlySet<string>>( + () => new Set(), + ); const committedChannelsKey = useMemo( () => Array.from(committedExcludedChannels).sort().join(" "), [committedExcludedChannels], @@ -396,6 +407,9 @@ function useSearchSessionState() { ); const [draftDateFrom, setDraftDateFrom] = useState(""); const [draftDateTo, setDraftDateTo] = useState(""); + const [draftTags, setDraftTags] = useState<ReadonlySet<string>>( + () => new Set(), + ); const [hydrated, setHydrated] = useState(false); const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({}); @@ -703,6 +717,14 @@ function useSearchSessionState() { if (t.isLivestream ? committedNol : committedNov) return false; if (t.ageRestricted ? committedNar : committedNaa) return false; if (!committedStates.has(summaryState(t))) return false; + // tg — curated tags, ORed across the selection. Mirrors + // lib/search/evalTree.ts:passesFilters, which is the same rule applied by + // the MCP and the record-level evaluator; when one moves the other must. + if (committedTags.size > 0) { + const on = t.curatedTags; + if (!on || on.length === 0) return false; + if (!on.some((id) => committedTags.has(id))) return false; + } return true; }; }, [ @@ -715,6 +737,7 @@ function useSearchSessionState() { committedNaa, committedNar, committedStates, + committedTags, ]); const committedStatesKey = useMemo( @@ -722,7 +745,14 @@ function useSearchSessionState() { [committedStates], ); - const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}`; + // Sorted, so a selection built in a different click order is the same key and + // does not re-run the pipeline. + const committedTagsKey = useMemo( + () => Array.from(committedTags).sort().join(" "), + [committedTags], + ); + + const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}|${committedTagsKey}`; // The scope universe is videos AND posts. The two namespaces are disjoint; // searchEval partitions them per-leaf (see EvalCtx.postScopeSlugs) so a posts @@ -732,7 +762,12 @@ function useSearchSessionState() { if (!transcripts) return []; const out: string[] = []; for (const t of transcripts) if (passesFilter(t)) out.push(t.slug); - if (!committedNop) for (const slug of postScopeSlugs) out.push(slug); + // A post carries no curated tags, so a tag filter drops the whole posts + // corpus rather than letting it through un-filtered — the same answer + // passesFilter gives an untagged video. + if (!committedNop && committedTags.size === 0) { + for (const slug of postScopeSlugs) out.push(slug); + } return out; // eslint-disable-next-line react-hooks/exhaustive-deps }, [transcripts, filterKey, postScopeSlugs, committedNop]); @@ -822,6 +857,9 @@ function useSearchSessionState() { platform: t.platform, uploadDate: t.uploadDate, hits: [], + ...(t.curatedTags && t.curatedTags.length > 0 + ? { curatedTags: t.curatedTags } + : {}), accent: accentOf(splitId(t.slug).origin), }); } @@ -846,6 +884,9 @@ function useSearchSessionState() { platform: t.platform, uploadDate: t.uploadDate, hits: hitsBySlug.get(t.slug) ?? [], + ...(t.curatedTags && t.curatedTags.length > 0 + ? { curatedTags: t.curatedTags } + : {}), accent: accentOf(splitId(t.slug).origin), }); } @@ -860,6 +901,8 @@ function useSearchSessionState() { if (!post) continue; if (committedDateFrom && post.uploadDate < committedDateFrom) continue; if (committedDateTo && post.uploadDate > committedDateTo) continue; + // See globalScopeSlugs: a tag filter excludes posts, which carry none. + if (committedTags.size > 0) continue; // A post is either still up or deleted at the source — the two states of // the six that can apply to it — so it participates in the same // availability filter videos use. @@ -897,6 +940,7 @@ function useSearchSessionState() { committedDateFrom, committedDateTo, committedStates, + committedTags, ]); const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>(); @@ -943,7 +987,8 @@ function useSearchSessionState() { draftNar !== committedNar || !sameSet(draftStates, committedStates) || draftDateFrom !== committedDateFrom || - draftDateTo !== committedDateTo; + draftDateTo !== committedDateTo || + !sameSet(draftTags, committedTags); const committedSnapshot = useMemo<FilterSnapshot>(() => { const deltas = buildSnapshotDeltas( @@ -959,6 +1004,9 @@ function useSearchSessionState() { writeSnapshotStates(snap, committedStates); if (committedDateFrom) snap.dateFrom = committedDateFrom; if (committedDateTo) snap.dateTo = committedDateTo; + // Omitted when empty, so a profile saved on an untagged site stays + // byte-identical to one saved before tags existed. + if (committedTags.size > 0) snap.tg = Array.from(committedTags).sort(); snap.query = stringifyRoot(committedRoot); return snap; }, [ @@ -972,6 +1020,7 @@ function useSearchSessionState() { committedStates, committedDateFrom, committedDateTo, + committedTags, committedRoot, ]); @@ -991,6 +1040,7 @@ function useSearchSessionState() { writeSnapshotStates(snap, draftStates); if (draftDateFrom) snap.dateFrom = draftDateFrom; if (draftDateTo) snap.dateTo = draftDateTo; + if (draftTags.size > 0) snap.tg = Array.from(draftTags).sort(); snap.query = stringifyRoot(draftRoot); return snap; }, [ @@ -1005,6 +1055,7 @@ function useSearchSessionState() { draftStates, draftDateFrom, draftDateTo, + draftTags, draftRoot, ]); @@ -1018,6 +1069,9 @@ function useSearchSessionState() { setCommittedStates(new Set(draftStates)); setCommittedDateFrom(draftDateFrom); setCommittedDateTo(draftDateTo); + setCommittedTags(new Set(draftTags)); + // `tg` survives this on purpose: it is not a legacy or share-v1 key, and + // the effect below rewrites it from the committed selection. stripAllFilterParamsFromUrl(); }, [ draftExcludedChannels, @@ -1029,6 +1083,7 @@ function useSearchSessionState() { draftStates, draftDateFrom, draftDateTo, + draftTags, ]); // ─── Hydration ──────────────────────────────────────────────────────────── @@ -1085,6 +1140,13 @@ function useSearchSessionState() { let initialStates: ReadonlySet<VideoState> = new Set(VIDEO_STATES); let initialDateFrom = ""; let initialDateTo = ""; + // `tg` is read from the URL FIRST and independently of the share-v1 / + // legacy branch below, because it is not part of either schema: it is the + // live representation of the committed tag selection, so its presence on + // the URL always wins and its absence falls through to the snapshot. + const tagsFromUrl = params.getAll("tg").filter((t) => t !== ""); + let initialTags: ReadonlySet<string> | null = + tagsFromUrl.length > 0 ? new Set(tagsFromUrl) : null; if (hasShareV1(search)) { const sel = parseShareV1(search, channelOptions); @@ -1138,6 +1200,7 @@ function useSearchSessionState() { initialStates = snapshotStates(snapshot); initialDateFrom = snapshot?.dateFrom ?? ""; initialDateTo = snapshot?.dateTo ?? ""; + initialTags ??= new Set(snapshot?.tg ?? []); if (!resolvedRoot && snapshot?.query) { resolvedRoot = parseRoot(snapshot.query); } @@ -1158,6 +1221,7 @@ function useSearchSessionState() { setDraftStates(new Set(initialStates)); setDraftDateFrom(initialDateFrom); setDraftDateTo(initialDateTo); + setDraftTags(new Set(initialTags ?? [])); setCommittedExcludedChannels(initialExcluded); setCommittedNov(initialNov); setCommittedNol(initialNol); @@ -1166,6 +1230,7 @@ function useSearchSessionState() { setCommittedStates(new Set(initialStates)); setCommittedDateFrom(initialDateFrom); setCommittedDateTo(initialDateTo); + setCommittedTags(new Set(initialTags ?? [])); // Chart view + shape (after query/filters so the default data source can // key off whether a query was restored). @@ -1195,6 +1260,17 @@ function useSearchSessionState() { writeViewChartParams(view, view === "chart" ? chartShape : null); }, [hydrated, view, chartShape]); + // `tg` likewise: the URL carries the COMMITTED tag selection at all times, so + // a chip survives a reload, a share of the address bar, and a profile load — + // without teaching the share-v1 schema or the strip-on-commit helper about a + // key that is not theirs. Sorted, so the same selection is always the same + // URL. Deliberately post-hydration only: writing before the URL has been + // read would erase the selection the link arrived with. + useEffect(() => { + if (!hydrated) return; + writeUrlParams({ tags: committedTagsKey ? committedTagsKey.split(" ") : [] }); + }, [hydrated, committedTagsKey]); + // Persist UI-only collapse state immediately on toggle. Loads + merges + // saves so concurrent writes to other top-level fields aren't clobbered. const persistUiCollapse = useCallback( @@ -1296,6 +1372,7 @@ function useSearchSessionState() { setDraftStates(snapshotStates(snapshot)); setDraftDateFrom(snapshot?.dateFrom ?? ""); setDraftDateTo(snapshot?.dateTo ?? ""); + setDraftTags(new Set(snapshot?.tg ?? [])); if (snapshot?.query) { const parsed = parseRoot(snapshot.query); if (parsed) setDraftRoot(parsed); @@ -1320,6 +1397,7 @@ function useSearchSessionState() { setCommittedStates(snapshotStates(snapshot)); setCommittedDateFrom(snapshot?.dateFrom ?? ""); setCommittedDateTo(snapshot?.dateTo ?? ""); + setCommittedTags(new Set(snapshot?.tg ?? [])); if (snapshot?.query) { const parsed = parseRoot(snapshot.query); if (parsed) setCommittedRoot(parsed); @@ -1461,6 +1539,18 @@ function useSearchSessionState() { setDraftExcludedChannels(excluded); }, [channelOptions, defaultSelectedChannels]); + // One click on a chip: select it, or (when it is already selected) clear it. + // The chip row is the whole control — there is no separate "clear tags" + // affordance, because a selected chip IS the clear affordance. + const toggleDraftTag = useCallback((id: string) => { + setDraftTags((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + const handleResetAllFilters = useCallback(() => { setActiveProfileName(null); writeStorage((s) => { @@ -1514,6 +1604,11 @@ function useSearchSessionState() { if (isNodeActive(committedRoot)) { params.set("qt", stringifyRoot(committedRoot)); } + // `tg` rides along under its own name rather than joining the share-v1 + // schema: buildShareSearchParams starts from an empty URLSearchParams, so + // without this a shared link would silently drop the tag selection it was + // copied with. + for (const id of Array.from(committedTags).sort()) params.append("tg", id); // Carry the chart view + its shape so a shared link opens straight to the // same chart (query/filters stay in qt= and the share params above). if (view === "chart") { @@ -1857,6 +1952,10 @@ function useSearchSessionState() { setDraftDateFrom, draftDateTo, setDraftDateTo, + // ── Curated tags (draft selection + what is actually being filtered on) ── + draftTags, + toggleDraftTag, + committedTags, // ── Advanced options ── hitBatchValue, setHitBatchValue, diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts @@ -45,6 +45,10 @@ export type FilterSnapshot = { dateTo?: string; mode?: SearchMode; tracks?: string[]; + // Curated tag ids selected (lib/curatedTags.ts), ORed. Absent => no tag + // filter, which is what every profile saved before tags existed means. NOT + // `tracks` above and not the yt-dlp keyword scope — see urlState's `tg`. + tg?: string[]; query?: string; }; @@ -151,6 +155,7 @@ function parseSnapshot(raw: unknown): FilterSnapshot | null { } if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode; if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice(); + if (isStringArray(r.tg)) snap.tg = r.tg.slice(); if (typeof r.query === "string") snap.query = r.query; return snap; } @@ -266,6 +271,7 @@ export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean { if ((a.dateTo ?? "") !== (b.dateTo ?? "")) return false; if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false; if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false; + if (sortedJson(a.tg ?? []) !== sortedJson(b.tg ?? [])) return false; if ((a.query ?? "") !== (b.query ?? "")) return false; return true; }