Archilyzer · Source

archilyzer

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

commit a94ccf323e8524ce9df2307d7a11d9edc004cafd
parent d1794d7c891f4e04740dcccec7fb5000170ad48f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 16:21:40 -0400

common: the Search in row — Transcripts, Posts, Live chat — on the export and hub search

The Filters panel gets a "Search in" row beside Type: Transcripts (on),
Posts (on; the Posts box moved out of the Type row, same key nop, same
name, same posts-corpus gate) and Live chat (off; offered only when the subs
manifest reports live chat). The session applies the row to the committed
tree before it runs and folds the progress back, so a plain query reads the
kinds ticked; the subs and posts manifests load from the rewritten trees.
With nothing ticked Search is disabled and refused (Enter, the sheet's
Apply, the profile saves), and the row says "Search in: pick at least one".

The row is stored like the other toggles (notr, nop, lc) in the working
snapshot and the profiles, and read from the stored snapshot whatever the
URL carries, since no link carries it. nop is now restored on reload and
committed on a profile load, and the committed snapshot writes it (all three
were missing). A live-chat hit wears its track badge; a section on a post's
card, or holding only chat hits, is named for what it holds. The bar's hint
says to tick Live chat under Search in.

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

Diffstat:
Mcommon/components/FiltersContainer.tsx | 5+++++
Mcommon/components/FiltersPanel.tsx | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcommon/components/SearchBar.tsx | 33++++++++++++++++++++++++++-------
Mcommon/components/SearchResults.tsx | 35+++++++++++++++++++++++++++++++----
Mcommon/components/SearchSessionContext.tsx | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
5 files changed, 256 insertions(+), 48 deletions(-)

diff --git a/common/components/FiltersContainer.tsx b/common/components/FiltersContainer.tsx @@ -28,9 +28,13 @@ import { useMediaQuery } from "../lib/useMediaQuery"; export function FiltersTrigger({ activeCount, onApply, + applyDisabled = false, }: { activeCount: number; onApply: () => void; + // Refused exactly when Search is ("Search in" has nothing ticked); the + // panel above it says which row to fix. + applyDisabled?: boolean; }) { const [open, setOpen] = useState(false); // From sm the sheet comes in from the right like the menu; on a phone it @@ -72,6 +76,7 @@ export function FiltersTrigger({ <Button type="button" className="w-full" + disabled={applyDisabled} onClick={() => { onApply(); setOpen(false); diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx @@ -75,6 +75,13 @@ export default function FiltersPanel({ draftNov, draftNop, setDraftNop, + draftNotr, + setDraftNotr, + draftLc, + setDraftLc, + draftSearchInEmpty, + hasPostsCorpus, + hasSubs, setDraftNov, draftNol, setDraftNol, @@ -99,11 +106,6 @@ export default function FiltersPanel({ setHitLimit, } = useSearchSession(); - // Only offer the Posts type toggle on a site that actually ships a posts - // corpus, so a pure-video deployment's filter row is unchanged. - const { postsManifest } = useSearchData(); - const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0; - const toggleState = useCallback( (state: VideoState, keep: boolean) => { setDraftStates((prev) => { @@ -407,6 +409,53 @@ export default function FiltersPanel({ </div> )} <TagChipRow /> + {/* What a plain query reads — the default "transcripts" leaf, not a + leaf asked for by name in the builder. Posts is offered only + where the site ships posts and Live chat only where it has live + chat; with nothing ticked Search is refused and this row says so. + Type below still says which records are listed. */} + <div + className="flex flex-wrap items-center gap-x-3 gap-y-1" + data-testid="search-in-row" + > + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Search in + </span> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNotr} + onCheckedChange={(value) => setDraftNotr(value !== true)} + /> + Transcripts + </label> + {hasPostsCorpus && ( + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNop} + onCheckedChange={(value) => setDraftNop(value !== true)} + /> + Posts + </label> + )} + {hasSubs && ( + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={draftLc} + onCheckedChange={(value) => setDraftLc(value === true)} + /> + 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> + )} + </div> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="text-xs uppercase tracking-wide text-muted-foreground"> Type @@ -425,19 +474,6 @@ export default function FiltersPanel({ /> Livestreams </label> - {/* The social-post corpus is a third media kind, not a video - sub-type — without its own toggle the video/livestream pair - would silently drop every post. Only offered when the site - actually ships posts. */} - {hasPostsCorpus && ( - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={!draftNop} - onCheckedChange={(value) => setDraftNop(value !== true)} - /> - Posts - </label> - )} </div> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="text-xs uppercase tracking-wide text-muted-foreground"> diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx @@ -41,6 +41,9 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { draftNov, draftNol, draftNop, + draftNotr, + draftLc, + draftSearchInEmpty, draftNaa, draftNar, draftStates, @@ -57,11 +60,15 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { const compact = isCompactRoot(draftRoot); const layers = compactLayerActions(draftRoot, setDraftRoot); - // What the Filters chip's badge counts: anything the reader has narrowed. + // What the Filters chip's badge counts: anything the reader has narrowed — + // and the "Search in" row off its default, which can widen (Live chat) as + // well as narrow, because either way a plain query no longer reads what it + // reads by default and the panel that says so may be behind the chip. const activeFilters = useMemo(() => { let n = 0; if (draftExcludedChannels.size > 0) n += 1; - if (draftNov || draftNol || draftNop) n += 1; + if (draftNotr || draftNop || draftLc) n += 1; + if (draftNov || draftNol) n += 1; if (draftNaa || draftNar) n += 1; if (draftStates.size < MISSING_STATES.length + 1) n += 1; if (draftDateFrom || draftDateTo) n += 1; @@ -72,6 +79,8 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { draftNov, draftNol, draftNop, + draftNotr, + draftLc, draftNaa, draftNar, draftStates, @@ -82,14 +91,22 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { // The line under the bar says what to do: after an edit that is not applied // yet, and before the first Search of the page life, when the results area - // is empty and waits for it. - const promptApply = queryDirty || filtersDirty || !searchedThisPageLife; + // is empty and waits for it. Not while Search is refused ("Search in" has + // nothing ticked) — the panel's own line says what to fix. + const promptApply = + !draftSearchInEmpty && (queryDirty || filtersDirty || !searchedThisPageLife); + // The hint points at the row, so it goes once the box is ticked. + const chatHint = hasSubs && !draftLc; const submit = ( <Button type="submit" 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. + disabled={draftSearchInEmpty} + title={draftSearchInEmpty ? "Search in: pick at least one" : undefined} variant={queryDirty || filtersDirty ? "default" : "outline"} className="shrink-0 h-11 sm:h-9" > @@ -199,6 +216,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { <FiltersTrigger activeCount={activeFilters} onApply={commitSearch} + applyDisabled={draftSearchInEmpty} /> )} {!inlineFilters && ( @@ -226,7 +244,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { {/* One status line under the bar instead of two hints competing for room inside it — and a SIBLING of the form, so the pinned block is never more than the input row plus the chips. */} - {(promptApply || hasSubs) && ( + {(promptApply || chatHint) && ( <p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs"> {/* The site's accent, not the warning hue: an unapplied edit is the next step, not a fault. `--brand` clears 4.5:1 on every @@ -236,13 +254,14 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) { Press Enter or click Search to apply </span> )} - {hasSubs && ( + {chatHint && ( <span className="text-muted-foreground" title={`${liveChatTotalCount} videos have live chat`} > Live chat available on {liveChatTotalCount} video - {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat. + {liveChatTotalCount === 1 ? "" : "s"} — tick Live chat under + Search in. </span> )} </p> diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -34,6 +34,7 @@ import { vodExpiry } from "../lib/vodExpiry"; import { Button } from "./ui/button"; import { LayerSwatch } from "./LayerSwatch"; import { SCOPE_LABELS } from "./QueryLeafView"; +import type { LayerScope } from "../lib/searchQuery"; import { ChartShapeControls } from "./charts/ChartShapeControls"; import { SearchChartPanel } from "./charts/SearchChartPanel"; import { @@ -770,7 +771,15 @@ const ResultCard = memo(function ResultCard({ of them "Transcripts", which is where the hits did NOT come from. */} <span className="text-[10px] uppercase tracking-wide text-muted-foreground"> - {SCOPE_LABELS[leafInfo.scope]} + { + SCOPE_LABELS[ + sectionScope( + leafInfo.scope, + buckets.get(leafId) ?? hits, + !!group.post, + ) + ] + } </span> <span className="font-mono text-xs text-muted-foreground truncate"> {leafInfo.query} @@ -918,9 +927,10 @@ const HitRow = memo(function HitRow({ {hit.scope === "metadata" ? "—" : formatSeconds(hit.start)} </span> )} - {hit.track && hit.track !== "live_chat" && ( - <TrackBadge track={hit.track} /> - )} + {/* Every track wears its badge, live chat included: with "Search in" + a plain query's live-chat hits share a section with its transcript + hits, and the badge is what tells the two apart. */} + {hit.track && <TrackBadge track={hit.track} />} <span className="flex-1 min-w-0 text-sm"> {highlight(hit.text, query, useRegex)} </span> @@ -930,6 +940,23 @@ const HitRow = memo(function HitRow({ }); HitRow.displayName = "HitRow"; +// What a section's bar names. Its leaf's scope — except that a plain +// ("transcripts") leaf reads what "Search in" ticks, so its section on a post's +// card holds post hits, and on a video whose only matches were in its live chat +// holds chat hits; the bar names what is there rather than a kind that was not +// read. Mixed transcript and chat hits keep "Transcripts", each chat hit +// wearing its "live chat" badge. +function sectionScope( + scope: LayerScope, + hits: ReadonlyArray<LayerHit>, + isPost: boolean, +): LayerScope { + if (scope !== "transcripts") return scope; + if (isPost) return "posts"; + if (hits.length > 0 && hits.every((h) => h.scope === "chat")) return "chat"; + return scope; +} + // Which social platform a post came from. Reuses the TrackBadge shape so post // rows sit visually alongside chat rows rather than inventing a new idiom. function PostBadge({ platform }: { platform: string }) { diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx @@ -35,16 +35,20 @@ import { type TreeProgress, } from "../lib/searchEval"; import { + applySearchIn, canonicalHash, emptyRoot, forEachLeaf, isNodeActive, parseRoot, rootFromLegacy, + searchInReadsNothing, stringifyRoot, type GroupNode, type LayerScope, + type SearchIn, } from "../lib/searchQuery"; +import { foldSearchIn } from "../lib/search/searchIn"; import { VIDEO_STATES, summaryState, @@ -396,6 +400,13 @@ function useSearchSessionState() { // (isLivestream ? nol : nov); a post is neither, so it needs its own toggle // or the existing two would silently drop the whole posts corpus. 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 + // like the others: `notr` = Transcripts unticked, `lc` = Live chat ticked. + // The row is applied to the committed tree just before it runs + // (`applySearchIn`); the tree itself, its hash and its `qt=` do not change. + const [committedNotr, setCommittedNotr] = useState(false); + const [committedLc, setCommittedLc] = useState(false); const [committedNaa, setCommittedNaa] = useState(false); const [committedNar, setCommittedNar] = useState(false); // Availability is a six-state enum (VideoState), not a set of "exclude X" @@ -446,6 +457,8 @@ function useSearchSessionState() { >(() => new Set()); const [draftNov, setDraftNov] = useState(false); const [draftNop, setDraftNop] = useState(false); + const [draftNotr, setDraftNotr] = useState(false); + const [draftLc, setDraftLc] = useState(false); const [draftNol, setDraftNol] = useState(false); const [draftNaa, setDraftNaa] = useState(false); const [draftNar, setDraftNar] = useState(false); @@ -523,13 +536,58 @@ function useSearchSessionState() { // only load subs manifests when a chat leaf is actually in the tree. const liveChatTotalCount = subsManifest?.liveChatTotalCount ?? 0; const hasSubs = subsManifest !== null && liveChatTotalCount > 0; + 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>( + () => ({ + transcripts: !draftNotr, + posts: !draftNop && hasPostsCorpus, + chat: draftLc && hasSubs, + }), + [draftNotr, draftNop, draftLc, hasPostsCorpus, hasSubs], + ); + // 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); + // 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 + // run's progress is folded back onto it (`foldSearchIn`). + const committedTree = useMemo( + () => applySearchIn(committedRoot, committedSearchIn), + [committedRoot, committedSearchIn], + ); + const committedTreeHash = useMemo( + () => canonicalHash(committedTree.root), + [committedTree], + ); + const draftTreeRoot = useMemo( + () => applySearchIn(draftRoot, draftSearchIn).root, + [draftRoot, draftSearchIn], + ); + // Read from the rewritten trees, so a plain query with Live chat ticked + // loads the subs manifests (and with Posts ticked, the posts manifests) + // exactly as a leaf asked for by name always has. const draftHasChatLeaf = useMemo( - () => anyLeafHasScope(draftRoot, "chat"), - [draftRoot], + () => anyLeafHasScope(draftTreeRoot, "chat"), + [draftTreeRoot], ); const committedHasChatLeaf = useMemo( - () => anyLeafHasScope(committedRoot, "chat"), - [committedRoot], + () => anyLeafHasScope(committedTree.root, "chat"), + [committedTree], ); const needsChatManifests = draftHasChatLeaf || committedHasChatLeaf; // Origin-qualified refs. In single-site mode the manifest slugs are bare, so @@ -576,12 +634,12 @@ function useSearchSessionState() { // manifests when a posts leaf is actually present, then flatten them into the // set of post slugs the tree may match. const draftHasPostsLeaf = useMemo( - () => anyLeafHasScope(draftRoot, "posts"), - [draftRoot], + () => anyLeafHasScope(draftTreeRoot, "posts"), + [draftTreeRoot], ); const committedHasPostsLeaf = useMemo( - () => anyLeafHasScope(committedRoot, "posts"), - [committedRoot], + () => anyLeafHasScope(committedTree.root, "posts"), + [committedTree], ); const needsPostsManifests = draftHasPostsLeaf || committedHasPostsLeaf; const postsRefs = useMemo( @@ -684,11 +742,12 @@ function useSearchSessionState() { const pipelineRef = useRef<TreeController | null>(null); const { openTranscript } = usePlayer(); - // Reset the live hit cap on each new committed query. + // Reset the live hit cap on each new committed query — including the same + // query read in other kinds ("Search in"). useEffect(() => { setHitLimit(hitBatchSize); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [committedHash]); + }, [committedHash, committedTreeHash]); // ─── Channel-grouping setup ──────────────────────────────────────────────── // Selection identity is the stable channelKey from the provider's model. In @@ -796,7 +855,7 @@ function useSearchSessionState() { [committedTags], ); - const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}|${committedTagsKey}`; + const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNotr ? 1 : 0}|${committedLc ? 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 @@ -858,7 +917,7 @@ function useSearchSessionState() { if (needsPostsManifests && !postsManifestReady) return; const controller = runQueryTree({ - root: committedRoot, + root: committedTree.root, runtime: searchRuntime, globalScope: globalScopeSlugs, summaries: transcripts, @@ -867,7 +926,9 @@ function useSearchSessionState() { initialHitLimit: hitLimit, concurrency: fetchConcurrency, flushIntervalMs, - emit: (p) => setTreeProgress(p), + // The copies "Search in" made report under ids of their own; fold them + // back under the leaf the visitor built before anything reads them. + emit: (p) => setTreeProgress(foldSearchIn(p, committedTree)), }); pipelineRef.current = controller; return () => { @@ -881,6 +942,7 @@ function useSearchSessionState() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ committedHash, + committedTreeHash, hasActiveQuery, transcripts, filterKey, @@ -1060,6 +1122,8 @@ function useSearchSessionState() { !sameSet(draftExcludedChannels, committedChannels) || draftNov !== committedNov || draftNop !== committedNop || + draftNotr !== committedNotr || + draftLc !== committedLc || draftNol !== committedNol || draftNaa !== committedNaa || draftNar !== committedNar || @@ -1076,6 +1140,11 @@ function useSearchSessionState() { ); const snap: FilterSnapshot = { channels: deltas }; if (committedNov) snap.nov = true; + // `nop` was missing here, so a profile with Posts unticked always read as + // changed against the committed state it had just loaded. + if (committedNop) snap.nop = true; + if (committedNotr) snap.notr = true; + if (committedLc) snap.lc = true; if (committedNol) snap.nol = true; if (committedNaa) snap.naa = true; if (committedNar) snap.nar = true; @@ -1092,6 +1161,9 @@ function useSearchSessionState() { channelOptions, defaultSelectedChannels, committedNov, + committedNop, + committedNotr, + committedLc, committedNol, committedNaa, committedNar, @@ -1112,6 +1184,8 @@ function useSearchSessionState() { }; if (draftNov) snap.nov = true; if (draftNop) snap.nop = true; + if (draftNotr) snap.notr = true; + if (draftLc) snap.lc = true; if (draftNol) snap.nol = true; if (draftNaa) snap.naa = true; if (draftNar) snap.nar = true; @@ -1127,6 +1201,8 @@ function useSearchSessionState() { defaultSelectedChannels, draftNov, draftNop, + draftNotr, + draftLc, draftNol, draftNaa, draftNar, @@ -1141,6 +1217,8 @@ function useSearchSessionState() { setCommittedExcludedChannels(new Set(draftExcludedChannels)); setCommittedNov(draftNov); setCommittedNop(draftNop); + setCommittedNotr(draftNotr); + setCommittedLc(draftLc); setCommittedNol(draftNol); setCommittedNaa(draftNaa); setCommittedNar(draftNar); @@ -1155,6 +1233,8 @@ function useSearchSessionState() { draftExcludedChannels, draftNov, draftNop, + draftNotr, + draftLc, draftNol, draftNaa, draftNar, @@ -1264,12 +1344,20 @@ function useSearchSessionState() { let resolvedRoot = rootFromUrl; // True only when the query came from the stored snapshot, never the URL. let holdRestored = false; + // The stored profile / working snapshot. + const snapshot = + stored?.activeProfileName != null + ? (stored.profiles[stored.activeProfileName] ?? stored.working) + : (stored?.working ?? null); + // The "Search in" row always comes from here, whatever the URL carries: no + // link carries it (share-v1 has no key for it, by ruling), so a shared + // link reads with the visitor's own row. Before the row, `nop` was read + // from nowhere, and a stored Posts-unticked came back ticked on reload. + const initialNop = snapshot?.nop === true; + const initialNotr = snapshot?.notr === true; + const initialLc = snapshot?.lc === true; 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, @@ -1307,7 +1395,9 @@ function useSearchSessionState() { setDraftExcludedChannels(initialExcluded); setDraftNov(initialNov); - setDraftNop(false); + setDraftNop(initialNop); + setDraftNotr(initialNotr); + setDraftLc(initialLc); setDraftNol(initialNol); setDraftNaa(initialNaa); setDraftNar(initialNar); @@ -1317,6 +1407,9 @@ function useSearchSessionState() { setDraftTags(new Set(initialTags ?? [])); setCommittedExcludedChannels(initialExcluded); setCommittedNov(initialNov); + setCommittedNop(initialNop); + setCommittedNotr(initialNotr); + setCommittedLc(initialLc); setCommittedNol(initialNol); setCommittedNaa(initialNaa); setCommittedNar(initialNar); @@ -1416,6 +1509,9 @@ function useSearchSessionState() { }, [persistUiCollapse]); const commitSearch = () => { + // "Search in" with nothing ticked reads nothing: refused here, which is + // where Search, Enter and the sheet's Apply all arrive. + if (draftSearchInEmpty) return; setRunHeld(false); ranThisPageLife = true; askedThisPageLife = true; @@ -1464,6 +1560,8 @@ function useSearchSessionState() { setDraftExcludedChannels(excluded); setDraftNov(snapshot?.nov === true); setDraftNop(snapshot?.nop === true); + setDraftNotr(snapshot?.notr === true); + setDraftLc(snapshot?.lc === true); setDraftNol(snapshot?.nol === true); setDraftNaa(snapshot?.naa === true); setDraftNar(snapshot?.nar === true); @@ -1494,6 +1592,11 @@ function useSearchSessionState() { ); setCommittedExcludedChannels(excluded); setCommittedNov(snapshot?.nov === true); + // `nop` was not committed here, so a loaded profile with Posts unticked + // kept searching posts until the next Search. + setCommittedNop(snapshot?.nop === true); + setCommittedNotr(snapshot?.notr === true); + setCommittedLc(snapshot?.lc === true); setCommittedNol(snapshot?.nol === true); setCommittedNaa(snapshot?.naa === true); setCommittedNar(snapshot?.nar === true); @@ -1547,6 +1650,8 @@ function useSearchSessionState() { const handleSaveProfile = useCallback(() => { if (!activeProfileName) return; + // A save commits the draft, so it refuses what Search refuses. + if (draftSearchInEmpty) return; const snap = buildDraftSnapshot(); promoteDraftsToCommitted(); setProfiles((prev) => { @@ -1559,6 +1664,7 @@ function useSearchSessionState() { }); }, [ activeProfileName, + draftSearchInEmpty, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage, @@ -1566,6 +1672,7 @@ function useSearchSessionState() { const handleSaveAsProfile = useCallback(() => { if (typeof window === "undefined") return; + if (draftSearchInEmpty) return; const raw = window.prompt("Save current filters as a new profile named:"); if (raw == null) return; const name = raw.trim(); @@ -1586,7 +1693,13 @@ function useSearchSessionState() { return next; }); setActiveProfileName(name); - }, [profiles, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage]); + }, [ + profiles, + draftSearchInEmpty, + buildDraftSnapshot, + promoteDraftsToCommitted, + writeStorage, + ]); const handleRenameProfile = useCallback(() => { if (typeof window === "undefined") return; @@ -2043,6 +2156,14 @@ function useSearchSessionState() { setDraftNov, draftNop, setDraftNop, + // ── Search in (the rest of the row; Posts is `draftNop` above) ── + draftNotr, + setDraftNotr, + draftLc, + setDraftLc, + // Nothing the site offers is ticked: Search is disabled and refused. + draftSearchInEmpty, + hasPostsCorpus, draftNol, setDraftNol, draftNaa,