Add season field to episode scripts and improve global search; v2.0.50
Publish Container / build (push) Successful in 16m53s
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:
1 parent
b236e69f22
commit
a6e8243705
5 files changed
+226
-41
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user