Add season field to episode scripts and improve global search; v2.0.50
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:
nmemmertandClaude Sonnet 5 committed 2026-09-24 08:45:40 -04:00
1 parent b236e69f22
commit a6e8243705
5 files changed
+226 -41

No files matched your search

+87 -11
View File
@@ -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()