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' /** * Simple whitespace-normalised search over episode script text. * Returns { episodeNumber, title, filename, 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 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 // Find a snippet around the first matching word const firstWord = words.find(w => text.includes(w)) const idx = firstWord ? text.indexOf(firstWord) : 0 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({ episodeNumber, title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', snippet, score: matchCount / words.length, // 0–1: fraction of query words found }) } 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(([episodeNumber, entry]) => ({ episodeNumber, title: entry.title ?? `Episode ${episodeNumber}`, filename: entry.filename ?? '', 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)) res.json({ scripts: list }) }) // Admin: upload a docx script for an episode app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => { 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}` 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) } state.episodeScripts[episodeNumber] = { title, filename, text, uploadedAt: new Date().toISOString(), wordCount: text.split(/\s+/).filter(Boolean).length, } queueEpisodeScriptsWrite() res.json({ ok: true, episodeNumber, wordCount: state.episodeScripts[episodeNumber].wordCount, }) }) // Admin: delete a script app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => { const episodeNumber = req.params.episodeNumber?.trim() if (!state.episodeScripts[episodeNumber]) { res.status(404).json({ message: 'Script not found.' }); return } delete state.episodeScripts[episodeNumber] queueEpisodeScriptsWrite() res.json({ ok: true }) }) }