From a6e82437058d7a1cf82fc7e867a64e4ee394b5c1 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 24 Sep 2026 08:45:40 -0400 Subject: [PATCH] Add season field to episode scripts and improve global search; v2.0.50 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 --- package.json | 2 +- server/routes/episode-scripts.js | 98 ++++++++++++++++++++++++++++---- server/state.js | 2 +- src/AdminPage.tsx | 91 +++++++++++++++++++++++++---- src/hooks/useGlobalSearch.ts | 74 ++++++++++++++++++------ 5 files changed, 226 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 5351779..eebe059 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.49", + "version": "2.0.50", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/episode-scripts.js b/server/routes/episode-scripts.js index 03459b4..c811c0d 100644 --- a/server/routes/episode-scripts.js +++ b/server/routes/episode-scripts.js @@ -3,28 +3,63 @@ import { requireAdminAuth } from '../auth.js' import { state } from '../state.js' import { queueEpisodeScriptsWrite } from '../data.js' import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js' +import { fetchAllEpisodes } from './episodes.js' + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Word-boundary containment so "sin" doesn't match inside "using"/"resin". +function containsWord(haystackLower, word) { + return new RegExp(`\\b${escapeRegExp(word)}\\b`, 'i').test(haystackLower) +} /** - * Simple whitespace-normalised search over episode script text. - * Returns { episodeNumber, title, filename, snippet, score } for matches. + * Whitespace-normalised search over episode script text, with word-boundary + * matching, an exact-phrase score boost, and a "season N" browse mode. + * Returns { episodeNumber, title, filename, season, snippet, score } for matches. */ function searchScripts(query) { if (!query || query.trim().length < 2) return [] - const q = query.trim().toLowerCase() - const words = q.split(/\s+/).filter(w => w.length >= 2) + const q = query.trim() + const qLower = q.toLowerCase() + + // "season N" browses every script tagged with that season, no text match needed. + const seasonQuery = /^season\s+(\d+)$/i.exec(q) + if (seasonQuery) { + const season = seasonQuery[1] + return Object.entries(state.episodeScripts) + .filter(([, entry]) => String(entry.season ?? '') === season) + .map(([episodeNumber, entry]) => ({ + episodeNumber, + title: entry.title ?? `Episode ${episodeNumber}`, + filename: entry.filename ?? '', + season: entry.season ?? null, + snippet: '', + score: 1, + })) + .sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)) + .slice(0, 8) + } + + const words = qLower.split(/\s+/).filter(w => w.length >= 2) + if (words.length === 0) return [] const results = [] for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { if (!entry?.text) continue const text = entry.text.toLowerCase() - // Count how many words appear in the text - const matchCount = words.filter(w => text.includes(w)).length - if (matchCount === 0) continue + const matchedWords = words.filter(w => containsWord(text, w)) + if (matchedWords.length === 0) continue + + // Exact multi-word phrase matches rank above partial word-overlap matches. + const isExactPhrase = words.length > 1 && text.includes(qLower) + const score = isExactPhrase ? 1 : matchedWords.length / words.length // Find a snippet around the first matching word - const firstWord = words.find(w => text.includes(w)) - const idx = firstWord ? text.indexOf(firstWord) : 0 + const firstWord = matchedWords[0] + const idx = text.indexOf(firstWord) const start = Math.max(0, idx - 80) const end = Math.min(entry.text.length, idx + 180) let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim() @@ -35,8 +70,9 @@ function searchScripts(query) { episodeNumber, title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', + season: entry.season ?? null, snippet, - score: matchCount / words.length, // 0–1: fraction of query words found + score, }) } @@ -57,10 +93,11 @@ export function register(app) { episodeNumber, title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', + season: entry.season ?? null, uploadedAt: entry.uploadedAt ?? null, wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0, })) - list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)) + list.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || Number(a.episodeNumber) - Number(b.episodeNumber)) res.json({ scripts: list }) }) @@ -74,6 +111,8 @@ export function register(app) { const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx` const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}` + const seasonRaw = typeof req.body?.season === 'string' ? req.body.season.trim() : (typeof req.body?.season === 'number' ? String(req.body.season) : '') + const season = /^\d+$/.test(seasonRaw) ? seasonRaw : null if (!dataUrl.startsWith('data:')) { res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return @@ -108,6 +147,7 @@ export function register(app) { state.episodeScripts[episodeNumber] = { title, filename, + season, text, uploadedAt: new Date().toISOString(), wordCount: text.split(/\s+/).filter(Boolean).length, @@ -117,10 +157,46 @@ export function register(app) { res.json({ ok: true, episodeNumber, + season, wordCount: state.episodeScripts[episodeNumber].wordCount, }) }) + // Admin: fill in season numbers for existing scripts from the podcast RSS feed + // (matches by episode number; never overwrites a season that's already set) + app.post('/api/admin-episode-scripts/backfill-seasons', requireAdminAuth, async (_req, res) => { + let episodes + try { + episodes = await fetchAllEpisodes() + } catch (err) { + console.error('[episode-scripts] backfill: RSS fetch failed:', err.message) + res.status(502).json({ message: 'Could not fetch the podcast RSS feed to look up seasons.' }) + return + } + + const seasonByEpisodeNumber = new Map() + for (const ep of episodes) { + if (ep.episode && ep.season) seasonByEpisodeNumber.set(ep.episode, ep.season) + } + + let updated = 0 + const stillMissing = [] + for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { + if (entry.season) continue + const found = seasonByEpisodeNumber.get(episodeNumber) + if (found) { + entry.season = found + updated += 1 + } else { + stillMissing.push(episodeNumber) + } + } + + if (updated > 0) queueEpisodeScriptsWrite() + + res.json({ ok: true, updated, stillMissing }) + }) + // Admin: delete a script app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => { const episodeNumber = req.params.episodeNumber?.trim() diff --git a/server/state.js b/server/state.js index 9f6df1b..04a6ead 100644 --- a/server/state.js +++ b/server/state.js @@ -54,7 +54,7 @@ export const state = { studyCertificates: [], studyCertificatesWritePromise: Promise.resolve(), - // episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } } + // episodeScripts: { [episodeNumber]: { text, title, filename, season, uploadedAt } } episodeScripts: {}, episodeScriptsWritePromise: Promise.resolve(), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 681567e..b8d7ddf 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -1586,13 +1586,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [commentSearch, setCommentSearch] = useState('') // Episode scripts state - const [scriptsList, setScriptsList] = useState>([]) + const [scriptsList, setScriptsList] = useState>([]) const [scriptsLoading, setScriptsLoading] = useState(false) const [scriptUploadEpisode, setScriptUploadEpisode] = useState('') const [scriptUploadTitle, setScriptUploadTitle] = useState('') + const [scriptUploadSeason, setScriptUploadSeason] = useState('') const [scriptUploadFile, setScriptUploadFile] = useState(null) const [scriptUploadBusy, setScriptUploadBusy] = useState(false) const [scriptUploadMsg, setScriptUploadMsg] = useState('') + const [scriptSeasonFilter, setScriptSeasonFilter] = useState('all') + const [scriptBackfillMsg, setScriptBackfillMsg] = useState('') // QR codes state interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number } @@ -2997,6 +3000,9 @@ async function handleAssetUpload(event: ChangeEvent) { if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) { setScriptUploadMsg('Enter a valid episode number.'); return } + if (scriptUploadSeason.trim() && !/^\d+$/.test(scriptUploadSeason.trim())) { + setScriptUploadMsg('Season must be a positive whole number.'); return + } if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return } setScriptUploadBusy(true) setScriptUploadMsg('') @@ -3010,13 +3016,19 @@ async function handleAssetUpload(event: ChangeEvent) { const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }), + body: JSON.stringify({ + dataUrl, + filename: scriptUploadFile.name, + title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}`, + season: scriptUploadSeason.trim(), + }), }) const data = await res.json() if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return } setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`) setScriptUploadEpisode('') setScriptUploadTitle('') + setScriptUploadSeason('') setScriptUploadFile(null) loadScriptsList() } catch { @@ -3026,6 +3038,26 @@ async function handleAssetUpload(event: ChangeEvent) { } } + async function handleBackfillSeasons() { + setScriptsLoading(true) + setScriptBackfillMsg('') + try { + const res = await fetch('/api/admin-episode-scripts/backfill-seasons', { method: 'POST' }) + const data = await res.json() + if (!res.ok) { setScriptBackfillMsg(data.message ?? 'Backfill failed.'); return } + const missing = (data.stillMissing ?? []) as string[] + setScriptBackfillMsg( + `✓ Filled in season for ${data.updated} script${data.updated === 1 ? '' : 's'} from the RSS feed.` + + (missing.length > 0 ? ` ${missing.length} episode${missing.length === 1 ? '' : 's'} not found in the feed (set season manually): ${missing.join(', ')}.` : '') + ) + await loadScriptsList() + } catch { + setScriptBackfillMsg('Backfill failed. Please try again.') + } finally { + setScriptsLoading(false) + } + } + async function handleDeleteScript(episodeNumber: string) { if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return try { @@ -5227,6 +5259,17 @@ async function handleAssetUpload(event: ChangeEvent) { onChange={e => setScriptUploadTitle(e.target.value)} /> +
+ + setScriptUploadSeason(e.target.value)} + /> +
) { {/* Indexed scripts list */}

Indexed Scripts

- +
+ {scriptBackfillMsg && ( +

+ {scriptBackfillMsg} +

+ )} {scriptsList.length === 0 ? (

No scripts uploaded yet.{' '} @@ -5258,16 +5322,19 @@ async function handleAssetUpload(event: ChangeEvent) {

) : (
- {scriptsList.map(script => ( -
-
- Ep. {script.episodeNumber} - {script.title} - {script.wordCount.toLocaleString()} words · {script.filename} + {scriptsList + .filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter) + .map(script => ( +
+
+ Ep. {script.episodeNumber} + {script.season && Season {script.season}} + {script.title} + {script.wordCount.toLocaleString()} words · {script.filename} +
+
- -
- ))} + ))}
)} diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index aa4290a..a9dc0b7 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -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() + 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 "-" 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() + 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 }