import mammoth from 'mammoth' 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' import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.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) } /** * 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() 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(([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) } const words = qLower.split(/\s+/).filter(w => w.length >= 2) if (words.length === 0) return [] const results = [] 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)) 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 = 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() if (start > 0) snippet = '…' + snippet if (end < entry.text.length) snippet = snippet + '…' results.push({ key, episodeNumber, title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', season: entry.season ?? null, snippet, score, }) } return results.sort((a, b) => b.score - a.score).slice(0, 8) } export function register(app) { // Public script search — called by the frontend global search app.get('/api/episode-scripts/search', (req, res) => { const q = typeof req.query.q === 'string' ? req.query.q.trim() : '' if (!q || q.length < 2) { res.json({ results: [] }); return } res.json({ results: searchScripts(q) }) }) // Admin: list all uploaded scripts app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => { 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, uploadedAt: entry.uploadedAt ?? null, wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0, })) list.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || Number(a.episodeNumber) - Number(b.episodeNumber)) res.json({ scripts: list }) }) // Admin: upload a docx script for an episode 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 } 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 } const base64 = dataUrl.split(',')[1] ?? '' if (!base64) { res.status(400).json({ message: 'Empty file.' }); return } const buffer = Buffer.from(base64, 'base64') if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) { res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return } let text try { const result = await mammoth.extractRawText({ buffer }) text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim() } catch (err) { console.error('[episode-scripts] mammoth extraction failed:', err.message) res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return } if (!text || text.length < 10) { res.status(422).json({ message: 'No readable text found in document.' }); return } // Trim to max length if (text.length > MAX_EPISODE_SCRIPT_LENGTH) { text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH) } 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, text, uploadedAt: new Date().toISOString(), wordCount: text.split(/\s+/).filter(Boolean).length, } queueEpisodeScriptsWrite() res.json({ ok: true, key, episodeNumber, season, wordCount: state.episodeScripts[key].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 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) 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 [key, entry] of Object.entries({ ...state.episodeScripts })) { if (entry.season) continue 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() res.json({ ok: true, updated, stillMissing }) }) // Admin: delete a script 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[key] queueEpisodeScriptsWrite() res.json({ ok: true }) }) }