Add season field to episode scripts and improve global search; v2.0.50
Publish Container / build (push) Successful in 16m53s
Publish Container / build (push) Successful in 16m53s
Tag scripts with a season number (with an RSS-feed backfill for existing uploads), surface it in search results, and fix word-boundary matching plus a title/script-match dedup bug in the search merge logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b236e69f22
commit
a6e8243705
5 files changed
+226
-41
No files matched your search
@@ -27,6 +27,7 @@ interface ScriptSearchResult {
|
||||
episodeNumber: string
|
||||
title: string
|
||||
filename: string
|
||||
season: string | null
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
@@ -94,6 +95,15 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Season number → book/study title, so script results can show "Romans" instead of "Season 3"
|
||||
const seasonTitleMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const book of content.finishedBooks ?? []) {
|
||||
if (book.season != null && book.title) map.set(String(book.season), book.title)
|
||||
}
|
||||
return map
|
||||
}, [content.finishedBooks])
|
||||
|
||||
// Debounced server-side script search — runs whenever query changes
|
||||
useEffect(() => {
|
||||
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
|
||||
@@ -105,20 +115,23 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
fetch(`/api/episode-scripts/search?q=${encodeURIComponent(query.trim())}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then((data: { results?: ScriptSearchResult[] }) => {
|
||||
const results = (data.results ?? []).map(r => ({
|
||||
id: `script-${r.episodeNumber}`,
|
||||
type: 'episode' as const,
|
||||
title: r.title,
|
||||
subtitle: `Episode ${r.episodeNumber} · Script`,
|
||||
description: r.snippet,
|
||||
scriptSnippet: r.snippet,
|
||||
href: `/episodes`,
|
||||
}))
|
||||
const results = (data.results ?? []).map(r => {
|
||||
const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null
|
||||
return {
|
||||
id: `script-${r.episodeNumber}`,
|
||||
type: 'episode' as const,
|
||||
title: r.title,
|
||||
subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '),
|
||||
description: r.snippet || undefined,
|
||||
scriptSnippet: r.snippet || undefined,
|
||||
href: `/episodes`,
|
||||
}
|
||||
})
|
||||
setScriptResults(results)
|
||||
})
|
||||
.catch(() => setScriptResults([]))
|
||||
}, 200)
|
||||
}, [query])
|
||||
}, [query, seasonTitleMap])
|
||||
|
||||
// Build the Fuse corpus: episodes + resources + series + Q&A
|
||||
const staticItems: SearchResult[] = useMemo(() => {
|
||||
@@ -168,14 +181,43 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
const q = query.trim()
|
||||
if (!q || q.length < 2) return []
|
||||
|
||||
const fuseMatches = fuse.search(q).slice(0, 6).map(r => r.item)
|
||||
// Fuse's own score is 0 (perfect) to 1 (worst) — flip it so higher is better,
|
||||
// and comparable to the script-search endpoint's 0–1 "higher is better" score.
|
||||
const fuseMatches = fuse.search(q).slice(0, 10).map(r => ({ item: r.item, score: 1 - (r.score ?? 0) }))
|
||||
|
||||
// Merge script results — deduplicate by episode id (prefer script match if already in fuse)
|
||||
const fuseIds = new Set(fuseMatches.map(r => r.id))
|
||||
const dedupedScripts = scriptResults.filter(r => !fuseIds.has(r.id))
|
||||
// Episode/nuggets/script ids all end in "-<episodeNumber>" when a number is known;
|
||||
// use that to merge a title match and a script-content match into one row instead
|
||||
// of showing the same episode twice.
|
||||
const episodeNumberFromId = (id: string): string | null => {
|
||||
const m = /^(?:episode|nuggets|script)-(\d+)$/.exec(id)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// Interleave: fuse results first, then script-only hits, total cap 8
|
||||
return [...fuseMatches, ...dedupedScripts].slice(0, 8)
|
||||
const merged = [...fuseMatches]
|
||||
const indexByEpisode = new Map<string, number>()
|
||||
merged.forEach((m, i) => {
|
||||
const num = episodeNumberFromId(m.item.id)
|
||||
if (num) indexByEpisode.set(num, i)
|
||||
})
|
||||
|
||||
for (const scriptItem of scriptResults) {
|
||||
const num = episodeNumberFromId(scriptItem.id)
|
||||
const existingIdx = num ? indexByEpisode.get(num) : undefined
|
||||
if (existingIdx != null) {
|
||||
// Same episode already matched by title/description — attach the script
|
||||
// snippet so the row shows why it matched, and boost its relevance.
|
||||
const existing = merged[existingIdx]
|
||||
merged[existingIdx] = {
|
||||
item: existing.item.scriptSnippet ? existing.item : { ...existing.item, scriptSnippet: scriptItem.scriptSnippet },
|
||||
score: Math.max(existing.score, 0.9),
|
||||
}
|
||||
} else {
|
||||
merged.push({ item: scriptItem, score: 0.9 })
|
||||
if (num) indexByEpisode.set(num, merged.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return merged.sort((a, b) => b.score - a.score).slice(0, 8).map(m => m.item)
|
||||
}, [fuse, query, scriptResults])
|
||||
|
||||
return { query, setQuery, results }
|
||||
|
||||
Reference in new issue
Block a user