Episode scripts: file by season + episode so numbers can repeat per book; search labels for current series; v2.9.6
Publish Container / build (push) Successful in 16m51s

- Scripts keyed "<season>-<episode>" (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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-08 15:31:11 -04:00
1 parent 4ddedf0f81
commit 752611d121
7 files changed
+108 -35

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.9.5", "version": "2.9.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -1
View File
@@ -1,3 +1,4 @@
import { migrateScriptKeys } from './episode-script-keys.js'
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
@@ -804,7 +805,11 @@ export async function loadEpisodeScriptsFromDisk() {
try { try {
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8') const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
const parsed = JSON.parse(raw) 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 { } catch {
state.episodeScripts = {} state.episodeScripts = {}
} }
+33
View File
@@ -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 }
}
+52 -21
View File
@@ -4,6 +4,7 @@ import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js' import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js' import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
import { fetchAllEpisodes } from './episodes.js' import { fetchAllEpisodes } from './episodes.js'
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
function escapeRegExp(s) { function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -30,14 +31,18 @@ function searchScripts(query) {
const season = seasonQuery[1] const season = seasonQuery[1]
return Object.entries(state.episodeScripts) return Object.entries(state.episodeScripts)
.filter(([, entry]) => String(entry.season ?? '') === season) .filter(([, entry]) => String(entry.season ?? '') === season)
.map(([episodeNumber, entry]) => ({ .map(([key, entry]) => {
const episodeNumber = scriptEpisodeNumber(key, entry)
return {
key,
episodeNumber, episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`, title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '', filename: entry.filename ?? '',
season: entry.season ?? null, season: entry.season ?? null,
snippet: '', snippet: '',
score: 1, score: 1,
})) }
})
.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)) .sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
.slice(0, 8) .slice(0, 8)
} }
@@ -46,8 +51,9 @@ function searchScripts(query) {
if (words.length === 0) return [] if (words.length === 0) return []
const results = [] const results = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { for (const [key, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue if (!entry?.text) continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const text = entry.text.toLowerCase() const text = entry.text.toLowerCase()
const matchedWords = words.filter(w => containsWord(text, w)) const matchedWords = words.filter(w => containsWord(text, w))
@@ -67,6 +73,7 @@ function searchScripts(query) {
if (end < entry.text.length) snippet = snippet + '…' if (end < entry.text.length) snippet = snippet + '…'
results.push({ results.push({
key,
episodeNumber, episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`, title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '', filename: entry.filename ?? '',
@@ -89,8 +96,9 @@ export function register(app) {
// Admin: list all uploaded scripts // Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => { app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({ const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({
episodeNumber, key,
episodeNumber: scriptEpisodeNumber(key, entry),
title: entry.title ?? `Episode ${episodeNumber}`, title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '', filename: entry.filename ?? '',
season: entry.season ?? null, season: entry.season ?? null,
@@ -102,7 +110,9 @@ export function register(app) {
}) })
// Admin: upload a docx script for an episode // 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() const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) { if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return 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) 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, title,
filename, filename,
season, season,
@@ -156,9 +172,10 @@ export function register(app) {
res.json({ res.json({
ok: true, ok: true,
key,
episodeNumber, episodeNumber,
season, season,
wordCount: state.episodeScripts[episodeNumber].wordCount, wordCount: state.episodeScripts[key].wordCount,
}) })
}) })
@@ -174,22 +191,36 @@ export function register(app) {
return 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) { 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 let updated = 0
const stillMissing = [] const stillMissing = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { for (const [key, entry] of Object.entries({ ...state.episodeScripts })) {
if (entry.season) continue if (entry.season) continue
const found = seasonByEpisodeNumber.get(episodeNumber) const episodeNumber = scriptEpisodeNumber(key, entry)
if (found) { const candidates = feedByEpisodeNumber.get(episodeNumber) ?? []
entry.season = found let match = candidates.length === 1 ? candidates[0] : undefined
updated += 1 // Several seasons share this episode number: fall back to matching the title
} else { if (!match && candidates.length > 1) {
stillMissing.push(episodeNumber) 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() if (updated > 0) queueEpisodeScriptsWrite()
@@ -198,12 +229,12 @@ export function register(app) {
}) })
// Admin: delete a script // Admin: delete a script
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => { app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim() const key = req.params.key?.trim()
if (!state.episodeScripts[episodeNumber]) { if (!key || !state.episodeScripts[key]) {
res.status(404).json({ message: 'Script not found.' }); return res.status(404).json({ message: 'Script not found.' }); return
} }
delete state.episodeScripts[episodeNumber] delete state.episodeScripts[key]
queueEpisodeScriptsWrite() queueEpisodeScriptsWrite()
res.json({ ok: true }) res.json({ ok: true })
}) })
+1 -1
View File
@@ -54,7 +54,7 @@ export const state = {
studyCertificates: [], studyCertificates: [],
studyCertificatesWritePromise: Promise.resolve(), studyCertificatesWritePromise: Promise.resolve(),
// episodeScripts: { [episodeNumber]: { text, title, filename, season, uploadedAt } } // episodeScripts: { ["<season>-<episode>" or "<episode>" if no season]: { episodeNumber, text, title, filename, season, uploadedAt } }
episodeScripts: {}, episodeScripts: {},
episodeScriptsWritePromise: Promise.resolve(), episodeScriptsWritePromise: Promise.resolve(),
+9 -9
View File
@@ -1670,7 +1670,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [commentSearch, setCommentSearch] = useState('') const [commentSearch, setCommentSearch] = useState('')
// Episode scripts state // Episode scripts state
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([]) const [scriptsList, setScriptsList] = useState<Array<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
const [scriptsLoading, setScriptsLoading] = useState(false) const [scriptsLoading, setScriptsLoading] = useState(false)
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('') const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
const [scriptUploadTitle, setScriptUploadTitle] = useState('') const [scriptUploadTitle, setScriptUploadTitle] = useState('')
@@ -3148,7 +3148,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return } 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('') setScriptUploadEpisode('')
setScriptUploadTitle('') setScriptUploadTitle('')
setScriptUploadSeason('') setScriptUploadSeason('')
@@ -3181,12 +3181,12 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
} }
} }
async function handleDeleteScript(episodeNumber: string) { async function handleDeleteScript(key: string, label: string) {
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return if (!confirm(`Remove script for ${label}?`)) return
try { 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 } 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.') } } catch { alert('Failed to delete script.') }
} }
@@ -5684,7 +5684,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
/> />
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional — matches the podcast season number)</span></label> <label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(recommended — scripts are filed by season + episode, so episode numbers can repeat in each book)</span></label>
<input <input
id="script-season" id="script-season"
type="number" type="number"
@@ -5749,14 +5749,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{scriptsList {scriptsList
.filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter) .filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter)
.map(script => ( .map(script => (
<div key={script.episodeNumber} className="admin-script-row"> <div key={script.key} className="admin-script-row">
<div className="admin-script-row-info"> <div className="admin-script-row-info">
<span className="admin-script-ep">Ep. {script.episodeNumber}</span> <span className="admin-script-ep">Ep. {script.episodeNumber}</span>
{script.season && <span className="admin-script-ep">Season {script.season}</span>} {script.season && <span className="admin-script-ep">Season {script.season}</span>}
<span className="admin-script-title">{script.title}</span> <span className="admin-script-title">{script.title}</span>
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span> <span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
</div> </div>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button> <button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.key, `Episode ${script.episodeNumber}${script.season ? ` (Season ${script.season})` : ''}`)}>Remove</button>
</div> </div>
))} ))}
</div> </div>
+6 -2
View File
@@ -25,6 +25,7 @@ interface PublicQuestion {
} }
interface ScriptSearchResult { interface ScriptSearchResult {
key?: string
episodeNumber: string episodeNumber: string
title: string title: string
filename: string filename: string
@@ -121,8 +122,11 @@ export function useGlobalSearch(content: SiteContent) {
for (const book of content.finishedBooks ?? []) { for (const book of content.finishedBooks ?? []) {
if (book.season != null && book.title) map.set(String(book.season), book.title) 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 return map
}, [content.finishedBooks]) }, [content.finishedBooks, content.seriesSeason, content.seriesTitle, content.secondSeries])
// Debounced server-side script search — runs whenever query changes // Debounced server-side script search — runs whenever query changes
useEffect(() => { useEffect(() => {
@@ -138,7 +142,7 @@ export function useGlobalSearch(content: SiteContent) {
const results = (data.results ?? []).map(r => { const results = (data.results ?? []).map(r => {
const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null
return { return {
id: `script-${r.episodeNumber}`, id: `script-${r.key ?? r.episodeNumber}`,
type: 'episode' as const, type: 'episode' as const,
title: r.title, title: r.title,
subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '), subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '),