Archilyzer · Source

archilyzer

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

commit c05af751a0b7eac0416e091c9772ad074e2c8ea2
parent 9af7e7bf1ffa687c18dddab1d60e69e7f43ca145
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed,  1 Jul 2026 23:47:41 -0400

Phase 4: stable channelKey for multi-origin channel selection

Channel selection identity moves from the display name to a stable channelKey
supplied by the SearchData provider. In single-site mode key === the channel
name, so name-keyed filter state, share links, and saved profiles round-trip
byte-for-byte; in hub mode the key will be origin-qualified (makeId(origin,
channelSlug)) so same-named channels from different sites don't collide.

- SearchDataContext: SearchDataValue gains channels: ChannelOption[]
  {key,name,groupId}, groups, defaultGroupId, and channelKeyOf(summary).
  SingleSiteDataProvider derives the model from the manifest (key=name), with
  the summaries-derived fallback preserved.
- TranscriptSearch: consumes the provider's channel model; channelOptions/
  exclusion sets are now keyed by channelKey with a channelLabelByKey lookup for
  display; passesFilter uses channelKeyOf(t); the group derivation that used to
  read the manifest now reads the provider model. Filter-UI checkboxes show the
  label, toggle by key.

The MultiSiteDataProvider merge lands with the hub page (Phase 6), where it's
mountable and testable.

Verified: 23/23 filter-profile / share-current-search / profile-row /
browse-all / search-date-filter / modal-share e2e green; all packages typecheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/SearchDataContext.tsx | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/components/TranscriptSearch.tsx | 157++++++++++++++++++++++++++++---------------------------------------------------
2 files changed, 138 insertions(+), 107 deletions(-)

diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx @@ -2,21 +2,47 @@ // Data source for TranscriptSearch. In single-site mode this is a thin wrapper // over the same summaries/subs hooks the component used to call directly, so -// behavior is byte-for-byte unchanged. In hub mode (added in a later phase) a -// MultiSiteDataProvider merges these feeds across many origins and hands the -// component one unified, origin-qualified view — so TranscriptSearch itself -// stays mode-agnostic and reads everything from this context. +// behavior is byte-for-byte unchanged. In hub mode (added when the hub page +// lands) a MultiSiteDataProvider merges these feeds across many origins and +// hands the component one unified, origin-qualified view — so TranscriptSearch +// itself stays mode-agnostic and reads everything from this context. -import { createContext, useContext, type ReactNode } from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { useSummaries, type SummariesState } from "./summariesCache"; import { useSubsManifest } from "./subsCache"; import type { SubsManifest } from "../lib/manifest"; +import { + DEFAULT_GROUP_FALLBACK_ID, + FALLBACK_GROUP, + resolveChannelGroupId, + type ChannelGroup, +} from "../lib/channelGroups"; + +// One selectable channel in the filter UI. `key` is the STABLE selection +// identity that filter state, share links, and saved profiles store: +// single-site: key === the display name (back-compat with name-keyed state) +// hub: key === makeId(origin, channelSlug) (collision-free across sites) +// `name` is always the human label shown in the UI. +export type ChannelOption = { + key: string; + name: string; + groupId: string; +}; + +// Minimal shape of a summary needed to derive its channel key. +type ChannelKeyable = { channel: string; channelSlug: string }; export type SearchDataValue = { // Summaries + cross-channel manifest. Merged across origins in hub mode. summariesState: SummariesState; // Cross-channel subs manifest (merged across origins in hub mode), or null. subsManifest: SubsManifest | null; + // The channel selection model, resolved mode-appropriately. + channels: ChannelOption[]; + groups: ChannelGroup[]; + defaultGroupId: string; + // Map a summary to its channel selection key (matches ChannelOption.key). + channelKeyOf: (t: ChannelKeyable) => string; }; const SearchDataContext = createContext<SearchDataValue | null>(null); @@ -30,11 +56,61 @@ export function useSearchData(): SearchDataValue { } // Single-site data source: the built-in export site (same-origin, origin=""). +// The channel key IS the display name here, so all name-keyed filter state, +// 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 manifest = summariesState.manifest; + + const groups = useMemo<ChannelGroup[]>(() => { + const src = manifest?.groups; + return src && src.length > 0 ? src : [{ ...FALLBACK_GROUP }]; + }, [manifest]); + + const defaultGroupId = useMemo<string>(() => { + const dg = manifest?.defaultGroupId; + if (typeof dg === "string" && groups.some((g) => g.id === dg)) return dg; + return groups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID; + }, [manifest, groups]); + + const channels = useMemo<ChannelOption[]>(() => { + if (manifest?.channels.length) { + return manifest.channels + .filter((c) => c.name) + .map((c) => ({ + key: c.name, + name: c.name, + groupId: resolveChannelGroupId(c.groupId, groups, defaultGroupId), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + // Fallback: no manifest channel list — derive names from loaded summaries. + const names = new Set<string>(); + for (const t of summariesState.summaries) if (t.channel) names.add(t.channel); + return Array.from(names) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ + key: name, + name, + groupId: resolveChannelGroupId(undefined, groups, defaultGroupId), + })); + }, [manifest, groups, defaultGroupId, summariesState.summaries]); + + const value = useMemo<SearchDataValue>( + () => ({ + summariesState, + subsManifest, + channels, + groups, + defaultGroupId, + channelKeyOf: (t) => t.channel, + }), + [summariesState, subsManifest, channels, groups, defaultGroupId], + ); + return ( - <SearchDataContext.Provider value={{ summariesState, subsManifest }}> + <SearchDataContext.Provider value={value}> {children} </SearchDataContext.Provider> ); diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -62,13 +62,7 @@ import { SearchChartPanel } from "./charts/SearchChartPanel"; import { LayerSwatch } from "./LayerSwatch"; import { ymdToInput, inputToYmd } from "../lib/ymd"; import type { DisplaySummary } from "../lib/transcripts"; -import { - DEFAULT_GROUP_FALLBACK_ID, - FALLBACK_GROUP, - resolveChannelGroupId, - sortGroups, - type ChannelGroup, -} from "../lib/channelGroups"; +import { sortGroups, type ChannelGroup } from "../lib/channelGroups"; type Summary = DisplaySummary; @@ -258,9 +252,17 @@ export default function TranscriptSearch() { useEffect(() => setMounted(true), []); // Data feeds come from the SearchData provider (single-site or, in hub mode, - // merged across origins) so this component is mode-agnostic. - const { summariesState, subsManifest } = useSearchData(); - const { manifest, summaries, loadedPages, pageCount, summariesReady } = + // merged across origins) so this component is mode-agnostic. `channels` is the + // selection model keyed by a stable channelKey (= the channel name in single- + // site mode, so name-keyed filter state / share links / profiles round-trip). + const { + summariesState, + subsManifest, + channels: channelModel, + groups: manifestGroups, + channelKeyOf, + } = useSearchData(); + const { summaries, loadedPages, pageCount, summariesReady } = summariesState; const transcripts: Summary[] | null = summariesReady ? summaries : null; @@ -364,120 +366,72 @@ export default function TranscriptSearch() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [committedHash]); - // ─── Channel-grouping setup (unchanged) ─────────────────────────────────── - const manifestGroups = useMemo<ChannelGroup[]>(() => { - const src = manifest?.groups; - if (src && src.length > 0) return src; - return [{ ...FALLBACK_GROUP }]; - }, [manifest]); - const manifestDefaultGroupId = useMemo<string>(() => { - const dg = manifest?.defaultGroupId; - if (typeof dg === "string" && manifestGroups.some((g) => g.id === dg)) { - return dg; - } - return manifestGroups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID; - }, [manifest, manifestGroups]); - - const channelGroupByName = useMemo<Map<string, string>>(() => { - const map = new Map<string, string>(); - if (manifest?.channels.length) { - for (const c of manifest.channels) { - if (!c.name) continue; - map.set( - c.name, - resolveChannelGroupId( - c.groupId, - manifestGroups, - manifestDefaultGroupId, - ), - ); - } - } - return map; - }, [manifest, manifestGroups, manifestDefaultGroupId]); - - const channelOptions = useMemo<string[]>(() => { - if (manifest?.channels.length) { - return manifest.channels - .map((c) => c.name) - .filter((n) => n) - .sort((a, b) => a.localeCompare(b)); - } - if (!transcripts) return []; - 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)); - }, [manifest, transcripts]); + // ─── Channel-grouping setup ──────────────────────────────────────────────── + // Selection identity is the stable channelKey from the provider's model. In + // single-site mode key === the channel name, so all name-keyed filter state, + // share links, and saved profiles round-trip exactly as before; in hub mode + // the key is origin-qualified so same-named channels from different sites + // don't collide. `channelOptions` holds keys; labels come from + // `channelLabelByKey` for display only. + const channelOptions = useMemo<string[]>( + () => channelModel.map((c) => c.key), + [channelModel], + ); + const channelLabelByKey = useMemo<Map<string, string>>( + () => new Map(channelModel.map((c) => [c.key, c.name])), + [channelModel], + ); const channelGroupings = useMemo< { group: ChannelGroup; channels: string[] }[] >(() => { - if (channelOptions.length === 0) return []; + if (channelModel.length === 0) return []; const byId = new Map<string, string[]>(); - for (const name of channelOptions) { - const gid = - channelGroupByName.get(name) ?? - resolveChannelGroupId( - undefined, - manifestGroups, - manifestDefaultGroupId, - ); - const list = byId.get(gid) ?? []; - list.push(name); - byId.set(gid, list); + for (const c of channelModel) { + const list = byId.get(c.groupId) ?? []; + list.push(c.key); + byId.set(c.groupId, list); } + const labelOf = (k: string) => channelLabelByKey.get(k) ?? k; const sorted = sortGroups(manifestGroups); const out: { group: ChannelGroup; channels: string[] }[] = []; for (const g of sorted) { - const channels = byId.get(g.id); - if (!channels || channels.length === 0) continue; + const keys = byId.get(g.id); + if (!keys || keys.length === 0) continue; out.push({ group: g, - channels: channels.slice().sort((a, b) => a.localeCompare(b)), + channels: keys + .slice() + .sort((a, b) => labelOf(a).localeCompare(labelOf(b))), }); } return out; - }, [ - channelOptions, - channelGroupByName, - manifestGroups, - manifestDefaultGroupId, - ]); + }, [channelModel, channelLabelByKey, manifestGroups]); const defaultSelectedChannels = useMemo<Set<string>>(() => { const out = new Set<string>(); const groupById = new Map(manifestGroups.map((g) => [g.id, g])); - for (const name of channelOptions) { - const gid = - channelGroupByName.get(name) ?? - resolveChannelGroupId( - undefined, - manifestGroups, - manifestDefaultGroupId, - ); - const g = groupById.get(gid); - if (g?.selectedByDefault) out.add(name); + for (const c of channelModel) { + if (groupById.get(c.groupId)?.selectedByDefault) out.add(c.key); } return out; - }, [ - channelOptions, - channelGroupByName, - manifestGroups, - manifestDefaultGroupId, - ]); + }, [channelModel, manifestGroups]); const selectedChannelNames = useMemo( - () => channelOptions.filter((name) => !draftExcludedChannels.has(name)), + () => channelOptions.filter((key) => !draftExcludedChannels.has(key)), [channelOptions, draftExcludedChannels], ); const selectedChannelsLabel = useMemo( - () => selectedChannelNames.join(", "), - [selectedChannelNames], + () => + selectedChannelNames + .map((k) => channelLabelByKey.get(k) ?? k) + .join(", "), + [selectedChannelNames, channelLabelByKey], ); const passesFilter = useMemo(() => { return (t: Summary) => { - if (committedChannels.has(t.channel)) return false; + if (committedChannels.has(channelKeyOf(t))) return false; if (committedDateFrom && t.uploadDate < committedDateFrom) return false; if (committedDateTo && t.uploadDate > committedDateTo) return false; if (t.isLivestream ? committedNol : committedNov) return false; @@ -496,6 +450,7 @@ export default function TranscriptSearch() { }; }, [ committedChannels, + channelKeyOf, committedDateFrom, committedDateTo, committedNov, @@ -1540,11 +1495,11 @@ export default function TranscriptSearch() { </div> )} <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2"> - {channels.map((name) => { - const checked = !draftExcludedChannels.has(name); + {channels.map((key) => { + const checked = !draftExcludedChannels.has(key); return ( <label - key={name} + key={key} className="flex items-center gap-1.5 select-none" > <Checkbox @@ -1552,13 +1507,13 @@ export default function TranscriptSearch() { onCheckedChange={(value) => { setDraftExcludedChannels((prev) => { const next = new Set(prev); - if (value === true) next.delete(name); - else next.add(name); + if (value === true) next.delete(key); + else next.add(key); return next; }); }} /> - {name} + {channelLabelByKey.get(key) ?? key} </label> ); })}