Archilyzer · Source

archilyzer

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

commit 0e59ad100944b060f3942fc36028263249a0fdf5
parent 43a9646e8a9df09de6ea1b7522ee5f5fc502f09f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 17:44:18 -0400

common: Search in after the review — a "Posts" leaf is not the row's, no posts copy under a tag filter, the decision waits for the manifests, the refusal is described and the saves disabled

M2: nop no longer gates the global scope, so a leaf whose scope is "Posts"
reads posts with Posts unticked, as ruled; the box governs only the plain
query's posts copy. Under a curated-tag filter no posts copy is made
(searchInUnderTags: posts carry no tags), unless posts are all the row
reads, where the copy stays and reads nothing. L4: the SearchData providers
say when the posts and subs manifests have settled (manifestsSettled; the
hub's is summariesReady), and until then Search is not refused and the
query does not run, so a stored row with Transcripts unticked never reads
transcripts first. L1: the row's status line is always mounted and empty
unless refused; the Search button is described by an always-mounted copy in
the bar. L2: Save and Save as are disabled while Search is refused.

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

Diffstat:
Mcommon/components/FiltersPanel.tsx | 30++++++++++++++++++++----------
Mcommon/components/SearchBar.tsx | 16+++++++++++++---
Mcommon/components/SearchDataContext.tsx | 23+++++++++++++++++++++--
Mcommon/components/SearchSessionContext.tsx | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcommon/components/exportFilterStorage.ts | 5+++--
Mcommon/lib/searchQuery.test.ts | 17+++++++++++++++++
Mcommon/lib/searchQuery.ts | 10++++++++++
7 files changed, 137 insertions(+), 38 deletions(-)

diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx @@ -27,6 +27,7 @@ import { clearAdvanced } from "./exportAdvancedStorage"; import { ymdToInput, inputToYmd } from "../lib/ymd"; import { useSearchSession, + SEARCH_IN_REFUSAL, DEFAULT_MAX_HITS, DEFAULT_FETCH_CONCURRENCY, DEFAULT_FLUSH_INTERVAL_MS, @@ -186,6 +187,7 @@ export default function FiltersPanel({ onDelete={handleDeleteProfile} onRevert={handleRevertProfile} onShareCurrentSearch={handleShareCurrentSearch} + saveDisabled={draftSearchInEmpty} /> {channelOptions.length > 0 && ( @@ -446,15 +448,18 @@ export default function FiltersPanel({ Live chat </label> )} - {draftSearchInEmpty && ( - <span - role="status" - data-testid="search-in-empty" - className="basis-full text-xs text-warning" - > - Search in: pick at least one - </span> - )} + {/* Always mounted, empty unless Search is refused: a live region + is announced when its content changes, which several screen + readers miss for a region that appears with its text. The + Search button is described by the bar's own copy of these + words (this panel may be a closed sheet). */} + <span + role="status" + data-testid="search-in-empty" + className={cn("text-xs text-warning", draftSearchInEmpty && "basis-full")} + > + {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""} + </span> </div> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="text-xs uppercase tracking-wide text-muted-foreground"> @@ -661,6 +666,7 @@ function ProfilesRow({ onDelete, onRevert, onShareCurrentSearch, + saveDisabled, }: { profileNames: string[]; activeProfileName: string | null; @@ -673,6 +679,9 @@ function ProfilesRow({ onDelete: () => void; onRevert: () => void; onShareCurrentSearch: () => void; + // A save commits the draft, so it is refused when Search is ("Search in" + // has nothing ticked). + saveDisabled: boolean; }) { const showRevert = activeProfileName != null && profileDirty; return ( @@ -711,7 +720,7 @@ function ProfilesRow({ variant="link" size="sm" onClick={onSave} - disabled={!activeProfileName || !profileDirty} + disabled={!activeProfileName || !profileDirty || saveDisabled} className="text-xs text-muted-foreground" > Save @@ -721,6 +730,7 @@ function ProfilesRow({ variant="link" size="sm" onClick={onSaveAs} + disabled={saveDisabled} className="text-xs text-muted-foreground" > Save as… diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx @@ -18,7 +18,7 @@ import { Button } from "./ui/button"; import QueryBuilder, { compactLayerActions } from "./QueryBuilder"; import { FiltersTrigger } from "./FiltersContainer"; import { useMediaQuery } from "../lib/useMediaQuery"; -import { useSearchSession } from "./SearchSessionContext"; +import { SEARCH_IN_REFUSAL, useSearchSession } from "./SearchSessionContext"; export default function SearchBar({ nav }: { nav?: ReactNode }) { const { @@ -104,9 +104,12 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { data-testid="search-submit" data-dirty={queryDirty || filtersDirty ? "true" : "false"} // "Search in" with nothing ticked reads nothing; commitSearch refuses it - // too, for Enter and the sheet's Apply. + // too, for Enter and the sheet's Apply. The reason is its description, + // from the always-mounted copy below (not only a `title`, which neither + // touch nor a keyboard reaches). disabled={draftSearchInEmpty} - title={draftSearchInEmpty ? "Search in: pick at least one" : undefined} + aria-describedby="search-in-refusal" + title={draftSearchInEmpty ? SEARCH_IN_REFUSAL : undefined} variant={queryDirty || filtersDirty ? "default" : "outline"} className="shrink-0 h-11 sm:h-9" > @@ -172,6 +175,13 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { {!compact && ( <div className="flex items-center gap-2 flex-wrap">{submit}</div> )} + {/* The Search button's description. Here rather than pointing at the + Filters panel's line, which below xl lives in a sheet that is not + mounted while closed. Not a live region: the panel's line is the + one announced, where the box was unticked. */} + <span id="search-in-refusal" className="sr-only"> + {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""} + </span> {/* The chip row. Rendered OUTSIDE the `mounted` gate so the workspace nav exists as plain anchors before hydration — a nav click lost to diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx @@ -58,6 +58,13 @@ export type SearchDataValue = { // many). Merged across origins in hub mode. Null / empty channel list when // this site ships no posts corpus. postsManifest: PostsManifest | null; + // The two manifests above have answered (or failed), so a null or an empty + // one means "this site has none", not "not answered yet". The "Search in" + // row waits for it before it refuses a Search or runs one: until then a + // stored row cannot be told apart from an empty one. Hub mode: the merge + // covers the ready archives only, and an archive is ready only once both + // its manifests have settled, so it is `summariesReady`. + manifestsSettled: boolean; // The channel selection model, resolved mode-appropriately. channels: ChannelOption[]; groups: ChannelGroup[]; @@ -147,8 +154,17 @@ export function useSearchData(): SearchDataValue { // share links, and saved profiles round-trip exactly as before. export function SingleSiteDataProvider({ children }: { children: ReactNode }) { const summariesState = useSummaries(""); - const subsManifest = useSubsManifest("").data ?? null; - const postsManifest = usePostsManifest("").data ?? null; + const subsQuery = useSubsManifest(""); + const postsQuery = usePostsManifest(""); + const subsManifest = subsQuery.data ?? null; + const postsManifest = postsQuery.data ?? null; + // Settled at the first failure, not after the retry: a site with no live + // chat ships no subs manifest, and its 404 must not hold a search for the + // retry's second. A retry that does answer later still arrives (the row then + // re-runs), as a late manifest always did. + const manifestsSettled = + (subsQuery.data !== undefined || subsQuery.isError || subsQuery.failureCount > 0) && + (postsQuery.data !== undefined || postsQuery.isError || postsQuery.failureCount > 0); const aliases = useSearchAliases(""); const curatedTags = useCuratedTags(""); const manifest = summariesState.manifest; @@ -192,6 +208,7 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) { summariesState, subsManifest, postsManifest, + manifestsSettled, channels, groups, defaultGroupId, @@ -204,6 +221,7 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) { summariesState, subsManifest, postsManifest, + manifestsSettled, channels, groups, defaultGroupId, @@ -815,6 +833,7 @@ export function MultiSiteDataProvider({ summariesState, subsManifest, postsManifest, + manifestsSettled: summariesState.summariesReady, channels, groups, defaultGroupId, diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx @@ -43,6 +43,7 @@ import { parseRoot, rootFromLegacy, searchInReadsNothing, + searchInUnderTags, stringifyRoot, type GroupNode, type LayerScope, @@ -183,6 +184,10 @@ export type LeafInfo = { // Advanced-pipeline defaults, surfaced so the bar's Advanced-options controls // (and their "Reset to defaults" button) share the exact same constants. export const DEFAULT_MAX_HITS = 500; + +// The words the Filters panel's "Search in" row and the Search button's +// description say while nothing the site offers is ticked. +export const SEARCH_IN_REFUSAL = "Search in: pick at least one"; export const DEFAULT_FETCH_CONCURRENCY = 6; export const DEFAULT_FLUSH_INTERVAL_MS = 120; @@ -396,9 +401,9 @@ function useSearchSessionState() { >(() => new Set()); const [committedNov, setCommittedNov] = useState(false); const [committedNol, setCommittedNol] = useState(false); - // Third media kind: social posts. Videos and livestreams are a binary split - // (isLivestream ? nol : nov); a post is neither, so it needs its own toggle - // or the existing two would silently drop the whole posts corpus. + // Posts, the "Search in" row's second box (it was a third media kind in the + // Type row until release 16): unticked, a plain query reads no posts. A leaf + // of scope "Posts" reads them whatever it says. const [committedNop, setCommittedNop] = useState(false); // The rest of the "Search in" row (release 16) beside `nop`: what a plain // query — a "transcripts" leaf — reads. Stored as off-the-default booleans @@ -521,6 +526,7 @@ function useSearchSessionState() { summariesState, subsManifest, postsManifest, + manifestsSettled, channels: channelModel, groups: manifestGroups, channelKeyOf, @@ -539,19 +545,11 @@ function useSearchSessionState() { const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0; // ─── Search in ──────────────────────────────────────────────────────────── - // What the row reads, as far as this site can: Posts counts only where the - // site ships posts and Live chat only where it has live chat (the panel - // offers neither box otherwise), so a stored `lc` on a site without chat - // asks for no manifest that is not there. - const committedSearchIn = useMemo<SearchIn>( - () => ({ - transcripts: !committedNotr, - posts: !committedNop && hasPostsCorpus, - chat: committedLc && hasSubs, - }), - [committedNotr, committedNop, committedLc, hasPostsCorpus, hasSubs], - ); - const draftSearchIn = useMemo<SearchIn>( + // What the row ticks, as far as this site can honour it: Posts counts only + // where the site ships posts and Live chat only where it has live chat (the + // panel offers neither box otherwise), so a stored `lc` on a site without + // chat asks for no manifest that is not there. + const draftOffered = useMemo<SearchIn>( () => ({ transcripts: !draftNotr, posts: !draftNop && hasPostsCorpus, @@ -559,9 +557,31 @@ function useSearchSessionState() { }), [draftNotr, draftNop, draftLc, hasPostsCorpus, hasSubs], ); + // What the rewrite reads: the same, less a posts copy under a tag filter, + // which no post can pass (`searchInUnderTags`). + const committedSearchIn = useMemo<SearchIn>( + () => + searchInUnderTags( + { + transcripts: !committedNotr, + posts: !committedNop && hasPostsCorpus, + chat: committedLc && hasSubs, + }, + committedTags.size > 0, + ), + [committedNotr, committedNop, committedLc, hasPostsCorpus, hasSubs, committedTags], + ); + const draftSearchIn = useMemo<SearchIn>( + () => searchInUnderTags(draftOffered, draftTags.size > 0), + [draftOffered, draftTags], + ); // Nothing ticked that this site offers: Search, Enter, Apply and the profile - // saves all refuse, and the panel says which row to fix. - const draftSearchInEmpty = searchInReadsNothing(draftSearchIn); + // saves all refuse, and the panel says which row to fix. Only once the + // manifests have answered: until then Posts and Live chat read as not + // offered, and a stored row with Transcripts unticked would read as empty. + // The run waits for them too (below), so the decision is held, not guessed. + const draftSearchInEmpty = + manifestsSettled && searchInReadsNothing(draftOffered); // The committed tree as it RUNS: every "transcripts" leaf rewritten to read // the kinds ticked. `committedRoot` stays the tree the visitor built — the // builder, the result cards' leaf sections and `qt=` read that one, and the @@ -870,12 +890,17 @@ function useSearchSessionState() { // passesFilter gives an untagged video. See tagFilteredPostScope below: // the DEFAULT scope is only half of it, because a `posts`-scope leaf // carries its own slug set past this. - if (!committedNop && committedTags.size === 0) { + // + // Not gated on `nop` (release 16, review M2): the Posts box governs the + // plain query's posts copy, through `applySearchIn`, and a leaf of scope + // "Posts" was asked for by name and reads posts whatever the row says. + // `postScopeSlugs` is empty unless some leaf reads posts. + if (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]); + }, [transcripts, filterKey, postScopeSlugs]); // The posts slug set as the PIPELINE sees it. A `posts`-scope leaf is fed // from here and not from the global scope above, so gating only the global @@ -913,6 +938,10 @@ function useSearchSessionState() { return; } if (!transcripts) return; + // "Search in" counts Posts and Live chat only once the site's manifests + // say it has them; a run before that would read the wrong kinds (with + // Transcripts unticked, transcripts) and run again. Hold it. + if (!manifestsSettled) return; if (needsChatManifests && !subsManifestReady) return; if (needsPostsManifests && !postsManifestReady) return; @@ -945,6 +974,7 @@ function useSearchSessionState() { committedTreeHash, hasActiveQuery, transcripts, + manifestsSettled, filterKey, needsChatManifests, subsManifestReady, @@ -1110,7 +1140,9 @@ function useSearchSessionState() { const indexLoading = hasActiveQuery && - (!summariesReady || (needsChatManifests && !subsManifestReady)); + (!summariesReady || + !manifestsSettled || + (needsChatManifests && !subsManifestReady)); // Browse mode still needs the summaries before it can list anything. const summariesLoading = !summariesReady; const searching = hasActiveQuery && totalToProcess > 0; diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts @@ -31,8 +31,9 @@ export type FilterSnapshot = { // before the row existed reads transcripts and posts, as it always did. // notr — Transcripts unticked (the transcript cues are not read) // nop — Posts unticked (the social-post corpus; before the row this was - // the Posts box in the Type row, and it still keeps the posts - // corpus out of the scope a "Posts" leaf reads) + // the Posts box in the Type row, and it also emptied a leaf of + // scope "Posts" — since release 16 it does not: a leaf asked for by + // name reads what it names) // lc — Live chat ticked (the live_chat track is read; off by default) notr?: boolean; nop?: boolean; diff --git a/common/lib/searchQuery.test.ts b/common/lib/searchQuery.test.ts @@ -9,6 +9,7 @@ import { newGroup, newLeaf, searchInReadsNothing, + searchInUnderTags, stringifyRoot, type GroupNode, type LeafNode, @@ -182,3 +183,19 @@ test("two transcripts leaves each get their own copies", () => { ["a", "a", "b", "b"], ); }); + +test("under a tag filter the posts copy is left out, unless posts are all the row reads", () => { + const all = { transcripts: true, posts: true, chat: true }; + assert.equal(searchInUnderTags(all, false), all, "no tag filter: unchanged"); + assert.deepEqual(searchInUnderTags(all, true), { ...all, posts: false }); + assert.deepEqual( + searchInUnderTags({ transcripts: false, posts: true, chat: true }, true), + { transcripts: false, posts: false, chat: true }, + ); + // Posts alone stay: the copy reads an empty scope (no results), rather than + // the row reading nothing and the leaf falling back to its transcripts. + const postsOnly = { transcripts: false, posts: true, chat: false }; + assert.equal(searchInUnderTags(postsOnly, true), postsOnly); + const noPosts = { transcripts: true, posts: false, chat: false }; + assert.equal(searchInUnderTags(noPosts, true), noPosts); +}); diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts @@ -499,6 +499,16 @@ export function searchInReadsNothing(s: SearchIn): boolean { return !s.transcripts && !s.posts && !s.chat; } +// Under a curated-tag filter no post can match — a post carries no curated +// tags — so a posts copy would read an empty scope and cost a posts-manifest +// load for nothing. It is left out, unless posts are all the row reads: then +// it stays (and reads nothing, which is the answer) so that the leaf does not +// fall back to reading its transcripts. +export function searchInUnderTags(s: SearchIn, tagFilterOn: boolean): SearchIn { + if (!tagFilterOn || !s.posts || (!s.transcripts && !s.chat)) return s; + return { ...s, posts: false }; +} + export type SearchInTree = { root: GroupNode; // A per-kind copy's leaf id → the id of the "transcripts" leaf it stands