From 752611d12188e5c476485396eb61fe0fc0df3fab Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 8 Oct 2026 15:31:11 -0400 Subject: [PATCH] Episode scripts: file by season + episode so numbers can repeat per book; search labels for current series; v2.9.6 - Scripts keyed "-" (legacy ones migrate on first start; unseasoned ones keep their plain key) - Season backfill handles episode numbers shared across seasons (title match fallback) - Fix: "Backfill seasons from RSS" was swallowed by the upload route - Global search labels script results with the title of the series playing now (both lanes), not just finished books Co-Authored-By: Claude Sonnet 5.5 --- package.json | 2 +- server/data.js | 7 ++- server/episode-script-keys.js | 33 +++++++++++++ server/routes/episode-scripts.js | 85 ++++++++++++++++++++++---------- server/state.js | 2 +- src/AdminPage.tsx | 18 +++---- src/hooks/useGlobalSearch.ts | 8 ++- 7 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 server/episode-script-keys.js diff --git a/package.json b/package.json index fa8da6a..559efa3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.9.5", + "version": "2.9.6", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index 38ad3a3..85764c3 100644 --- a/server/data.js +++ b/server/data.js @@ -1,3 +1,4 @@ +import { migrateScriptKeys } from './episode-script-keys.js' import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' @@ -804,7 +805,11 @@ export async function loadEpisodeScriptsFromDisk() { try { const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8') const parsed = JSON.parse(raw) - state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} + const loaded = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} + const { scripts, changed } = migrateScriptKeys(loaded) + state.episodeScripts = scripts + // Persist the move to season-episode keys once, so it isn't redone on every start + if (changed) queueEpisodeScriptsWrite() } catch { state.episodeScripts = {} } diff --git a/server/episode-script-keys.js b/server/episode-script-keys.js new file mode 100644 index 0000000..046f651 --- /dev/null +++ b/server/episode-script-keys.js @@ -0,0 +1,33 @@ +// Episode scripts are filed by season + episode number ("2-1"), because each book of the Bible +// is its own podcast season and episode numbers can restart in every season. A script with no +// season keeps its plain episode number as its key. + +export function scriptKey(season, episodeNumber) { + const ep = String(episodeNumber ?? '').trim() + const s = String(season ?? '').trim() + return /^\d+$/.test(s) ? `${s}-${ep}` : ep +} + +// The episode number a stored script belongs to (new entries record it; legacy ones are keyed by it). +export function scriptEpisodeNumber(key, entry) { + if (entry && typeof entry.episodeNumber === 'string' && entry.episodeNumber) return entry.episodeNumber + const parts = String(key).split('-') + return parts[parts.length - 1] +} + +// One-time move of legacy scripts ({ "7": { season: "1" } }) to season-episode keys ({ "1-7": ... }). +// Scripts without a season stay under their plain episode number. Never overwrites an existing key. +export function migrateScriptKeys(scripts) { + const next = {} + let changed = false + for (const [key, entry] of Object.entries(scripts ?? {})) { + if (!entry || typeof entry !== 'object') continue + const episodeNumber = scriptEpisodeNumber(key, entry) + const wanted = scriptKey(entry.season, episodeNumber) + const targetKey = wanted !== key && !(wanted in scripts) && !(wanted in next) ? wanted : key + const withEpisode = entry.episodeNumber === episodeNumber ? entry : { ...entry, episodeNumber } + if (targetKey !== key || withEpisode !== entry) changed = true + next[targetKey] = withEpisode + } + return { scripts: next, changed } +} diff --git a/server/routes/episode-scripts.js b/server/routes/episode-scripts.js index c811c0d..f189465 100644 --- a/server/routes/episode-scripts.js +++ b/server/routes/episode-scripts.js @@ -4,6 +4,7 @@ import { state } from '../state.js' import { queueEpisodeScriptsWrite } from '../data.js' import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js' import { fetchAllEpisodes } from './episodes.js' +import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js' function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -30,14 +31,18 @@ function searchScripts(query) { 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, - })) + .map(([key, entry]) => { + const episodeNumber = scriptEpisodeNumber(key, entry) + return { + key, + 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) } @@ -46,8 +51,9 @@ function searchScripts(query) { if (words.length === 0) return [] const results = [] - for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { + for (const [key, entry] of Object.entries(state.episodeScripts)) { if (!entry?.text) continue + const episodeNumber = scriptEpisodeNumber(key, entry) const text = entry.text.toLowerCase() const matchedWords = words.filter(w => containsWord(text, w)) @@ -67,6 +73,7 @@ function searchScripts(query) { if (end < entry.text.length) snippet = snippet + '…' results.push({ + key, episodeNumber, title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', @@ -89,8 +96,9 @@ export function register(app) { // Admin: list all uploaded scripts app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => { - const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({ - episodeNumber, + const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({ + key, + episodeNumber: scriptEpisodeNumber(key, entry), title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', season: entry.season ?? null, @@ -102,7 +110,9 @@ export function register(app) { }) // Admin: upload a docx script for an episode - app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => { + app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res, next) => { + // "backfill-seasons" is its own route below; don't let this one swallow it + if (req.params.episodeNumber === 'backfill-seasons') { next(); return } const episodeNumber = req.params.episodeNumber?.trim() if (!episodeNumber || !/^\d+$/.test(episodeNumber)) { res.status(400).json({ message: 'Episode number must be a positive integer.' }); return @@ -144,7 +154,13 @@ export function register(app) { text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH) } - state.episodeScripts[episodeNumber] = { + const key = scriptKey(season, episodeNumber) + // A script with no season that this upload replaces would otherwise linger as a duplicate + if (season && state.episodeScripts[episodeNumber] && !state.episodeScripts[episodeNumber].season) { + delete state.episodeScripts[episodeNumber] + } + state.episodeScripts[key] = { + episodeNumber, title, filename, season, @@ -156,9 +172,10 @@ export function register(app) { res.json({ ok: true, + key, episodeNumber, season, - wordCount: state.episodeScripts[episodeNumber].wordCount, + wordCount: state.episodeScripts[key].wordCount, }) }) @@ -174,22 +191,36 @@ export function register(app) { return } - const seasonByEpisodeNumber = new Map() + const normalize = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + // Episode numbers can repeat across seasons, so keep every season an episode number appears in. + const feedByEpisodeNumber = new Map() for (const ep of episodes) { - if (ep.episode && ep.season) seasonByEpisodeNumber.set(ep.episode, ep.season) + if (!ep.episode || !ep.season) continue + const list = feedByEpisodeNumber.get(ep.episode) ?? [] + list.push({ season: String(ep.season), title: normalize(ep.title) }) + feedByEpisodeNumber.set(ep.episode, list) } let updated = 0 const stillMissing = [] - for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { + for (const [key, 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) + const episodeNumber = scriptEpisodeNumber(key, entry) + const candidates = feedByEpisodeNumber.get(episodeNumber) ?? [] + let match = candidates.length === 1 ? candidates[0] : undefined + // Several seasons share this episode number: fall back to matching the title + if (!match && candidates.length > 1) { + const byTitle = candidates.filter(c => c.title && c.title === normalize(entry.title)) + if (byTitle.length === 1) match = byTitle[0] } + const newKey = match ? scriptKey(match.season, episodeNumber) : '' + if (!match || (newKey !== key && state.episodeScripts[newKey])) { + stillMissing.push(episodeNumber) + continue + } + delete state.episodeScripts[key] + state.episodeScripts[newKey] = { ...entry, season: match.season, episodeNumber } + updated += 1 } if (updated > 0) queueEpisodeScriptsWrite() @@ -198,12 +229,12 @@ export function register(app) { }) // Admin: delete a script - app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => { - const episodeNumber = req.params.episodeNumber?.trim() - if (!state.episodeScripts[episodeNumber]) { + app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => { + const key = req.params.key?.trim() + if (!key || !state.episodeScripts[key]) { res.status(404).json({ message: 'Script not found.' }); return } - delete state.episodeScripts[episodeNumber] + delete state.episodeScripts[key] queueEpisodeScriptsWrite() res.json({ ok: true }) }) diff --git a/server/state.js b/server/state.js index 05b95e8..b2e0a96 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, season, uploadedAt } } + // episodeScripts: { ["-" or "" if no season]: { episodeNumber, text, title, filename, season, uploadedAt } } episodeScripts: {}, episodeScriptsWritePromise: Promise.resolve(), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 8e902ac..c62bd39 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -1670,7 +1670,7 @@ 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('') @@ -3148,7 +3148,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) }) const data = await res.json() if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return } - setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`) + setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode}${scriptUploadSeason.trim() ? ` (Season ${scriptUploadSeason.trim()})` : ''} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`) setScriptUploadEpisode('') setScriptUploadTitle('') setScriptUploadSeason('') @@ -3181,12 +3181,12 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) } } - async function handleDeleteScript(episodeNumber: string) { - if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return + async function handleDeleteScript(key: string, label: string) { + if (!confirm(`Remove script for ${label}?`)) return try { - const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' }) + const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(key)}`, { method: 'DELETE' }) if (!res.ok) { alert('Failed to delete script.'); return } - setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber)) + setScriptsList(prev => prev.filter(s => s.key !== key)) } catch { alert('Failed to delete script.') } } @@ -5684,7 +5684,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) />
- + new Date()) {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 23ca7a3..3e246e4 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -25,6 +25,7 @@ interface PublicQuestion { } interface ScriptSearchResult { + key?: string episodeNumber: string title: string filename: string @@ -121,8 +122,11 @@ export function useGlobalSearch(content: SiteContent) { for (const book of content.finishedBooks ?? []) { if (book.season != null && book.title) map.set(String(book.season), book.title) } + // The series playing now (one or two at a time), so their scripts show a title too + if (content.seriesSeason != null && content.seriesTitle) map.set(String(content.seriesSeason), content.seriesTitle) + if (content.secondSeries?.season != null && content.secondSeries.title) map.set(String(content.secondSeries.season), content.secondSeries.title) return map - }, [content.finishedBooks]) + }, [content.finishedBooks, content.seriesSeason, content.seriesTitle, content.secondSeries]) // Debounced server-side script search — runs whenever query changes useEffect(() => { @@ -138,7 +142,7 @@ export function useGlobalSearch(content: SiteContent) { const results = (data.results ?? []).map(r => { const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null return { - id: `script-${r.episodeNumber}`, + id: `script-${r.key ?? r.episodeNumber}`, type: 'episode' as const, title: r.title, subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '),