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
+214
-29
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.49",
|
"version": "2.0.50",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,28 +3,63 @@ import { requireAdminAuth } from '../auth.js'
|
|||||||
import { state } from '../state.js'
|
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'
|
||||||
|
|
||||||
|
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.
|
* Whitespace-normalised search over episode script text, with word-boundary
|
||||||
* Returns { episodeNumber, title, filename, snippet, score } for matches.
|
* matching, an exact-phrase score boost, and a "season N" browse mode.
|
||||||
|
* Returns { episodeNumber, title, filename, season, snippet, score } for matches.
|
||||||
*/
|
*/
|
||||||
function searchScripts(query) {
|
function searchScripts(query) {
|
||||||
if (!query || query.trim().length < 2) return []
|
if (!query || query.trim().length < 2) return []
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim()
|
||||||
const words = q.split(/\s+/).filter(w => w.length >= 2)
|
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 = []
|
const results = []
|
||||||
|
|
||||||
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
||||||
if (!entry?.text) continue
|
if (!entry?.text) continue
|
||||||
const text = entry.text.toLowerCase()
|
const text = entry.text.toLowerCase()
|
||||||
|
|
||||||
// Count how many words appear in the text
|
const matchedWords = words.filter(w => containsWord(text, w))
|
||||||
const matchCount = words.filter(w => text.includes(w)).length
|
if (matchedWords.length === 0) continue
|
||||||
if (matchCount === 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
|
// Find a snippet around the first matching word
|
||||||
const firstWord = words.find(w => text.includes(w))
|
const firstWord = matchedWords[0]
|
||||||
const idx = firstWord ? text.indexOf(firstWord) : 0
|
const idx = text.indexOf(firstWord)
|
||||||
const start = Math.max(0, idx - 80)
|
const start = Math.max(0, idx - 80)
|
||||||
const end = Math.min(entry.text.length, idx + 180)
|
const end = Math.min(entry.text.length, idx + 180)
|
||||||
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
|
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||||
@@ -35,8 +70,9 @@ function searchScripts(query) {
|
|||||||
episodeNumber,
|
episodeNumber,
|
||||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||||
filename: entry.filename ?? '',
|
filename: entry.filename ?? '',
|
||||||
|
season: entry.season ?? null,
|
||||||
snippet,
|
snippet,
|
||||||
score: matchCount / words.length, // 0–1: fraction of query words found
|
score,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +93,11 @@ export function register(app) {
|
|||||||
episodeNumber,
|
episodeNumber,
|
||||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||||
filename: entry.filename ?? '',
|
filename: entry.filename ?? '',
|
||||||
|
season: entry.season ?? null,
|
||||||
uploadedAt: entry.uploadedAt ?? null,
|
uploadedAt: entry.uploadedAt ?? null,
|
||||||
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
|
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 })
|
res.json({ scripts: list })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,6 +111,8 @@ export function register(app) {
|
|||||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
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 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 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:')) {
|
if (!dataUrl.startsWith('data:')) {
|
||||||
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
|
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
|
||||||
@@ -108,6 +147,7 @@ export function register(app) {
|
|||||||
state.episodeScripts[episodeNumber] = {
|
state.episodeScripts[episodeNumber] = {
|
||||||
title,
|
title,
|
||||||
filename,
|
filename,
|
||||||
|
season,
|
||||||
text,
|
text,
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
wordCount: text.split(/\s+/).filter(Boolean).length,
|
wordCount: text.split(/\s+/).filter(Boolean).length,
|
||||||
@@ -117,10 +157,46 @@ export function register(app) {
|
|||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
episodeNumber,
|
episodeNumber,
|
||||||
|
season,
|
||||||
wordCount: state.episodeScripts[episodeNumber].wordCount,
|
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
|
// Admin: delete a script
|
||||||
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
|
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
|
||||||
const episodeNumber = req.params.episodeNumber?.trim()
|
const episodeNumber = req.params.episodeNumber?.trim()
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ export const state = {
|
|||||||
studyCertificates: [],
|
studyCertificates: [],
|
||||||
studyCertificatesWritePromise: Promise.resolve(),
|
studyCertificatesWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } }
|
// episodeScripts: { [episodeNumber]: { text, title, filename, season, uploadedAt } }
|
||||||
episodeScripts: {},
|
episodeScripts: {},
|
||||||
episodeScriptsWritePromise: Promise.resolve(),
|
episodeScriptsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
|||||||
+71
-4
@@ -1586,13 +1586,16 @@ 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; uploadedAt: string | null; wordCount: number }>>([])
|
const [scriptsList, setScriptsList] = useState<Array<{ 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('')
|
||||||
|
const [scriptUploadSeason, setScriptUploadSeason] = useState('')
|
||||||
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
|
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
|
||||||
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
|
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
|
||||||
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
|
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
|
||||||
|
const [scriptSeasonFilter, setScriptSeasonFilter] = useState('all')
|
||||||
|
const [scriptBackfillMsg, setScriptBackfillMsg] = useState('')
|
||||||
|
|
||||||
// QR codes state
|
// QR codes state
|
||||||
interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number }
|
interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number }
|
||||||
@@ -2997,6 +3000,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) {
|
if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) {
|
||||||
setScriptUploadMsg('Enter a valid episode number.'); return
|
setScriptUploadMsg('Enter a valid episode number.'); return
|
||||||
}
|
}
|
||||||
|
if (scriptUploadSeason.trim() && !/^\d+$/.test(scriptUploadSeason.trim())) {
|
||||||
|
setScriptUploadMsg('Season must be a positive whole number.'); return
|
||||||
|
}
|
||||||
if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return }
|
if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return }
|
||||||
setScriptUploadBusy(true)
|
setScriptUploadBusy(true)
|
||||||
setScriptUploadMsg('')
|
setScriptUploadMsg('')
|
||||||
@@ -3010,13 +3016,19 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, {
|
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }),
|
body: JSON.stringify({
|
||||||
|
dataUrl,
|
||||||
|
filename: scriptUploadFile.name,
|
||||||
|
title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}`,
|
||||||
|
season: scriptUploadSeason.trim(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
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} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
|
||||||
setScriptUploadEpisode('')
|
setScriptUploadEpisode('')
|
||||||
setScriptUploadTitle('')
|
setScriptUploadTitle('')
|
||||||
|
setScriptUploadSeason('')
|
||||||
setScriptUploadFile(null)
|
setScriptUploadFile(null)
|
||||||
loadScriptsList()
|
loadScriptsList()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3026,6 +3038,26 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBackfillSeasons() {
|
||||||
|
setScriptsLoading(true)
|
||||||
|
setScriptBackfillMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-episode-scripts/backfill-seasons', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { setScriptBackfillMsg(data.message ?? 'Backfill failed.'); return }
|
||||||
|
const missing = (data.stillMissing ?? []) as string[]
|
||||||
|
setScriptBackfillMsg(
|
||||||
|
`✓ Filled in season for ${data.updated} script${data.updated === 1 ? '' : 's'} from the RSS feed.` +
|
||||||
|
(missing.length > 0 ? ` ${missing.length} episode${missing.length === 1 ? '' : 's'} not found in the feed (set season manually): ${missing.join(', ')}.` : '')
|
||||||
|
)
|
||||||
|
await loadScriptsList()
|
||||||
|
} catch {
|
||||||
|
setScriptBackfillMsg('Backfill failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setScriptsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteScript(episodeNumber: string) {
|
async function handleDeleteScript(episodeNumber: string) {
|
||||||
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
|
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
|
||||||
try {
|
try {
|
||||||
@@ -5227,6 +5259,17 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
onChange={e => setScriptUploadTitle(e.target.value)}
|
onChange={e => setScriptUploadTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional — matches the podcast season number)</span></label>
|
||||||
|
<input
|
||||||
|
id="script-season"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="e.g. 3"
|
||||||
|
value={scriptUploadSeason}
|
||||||
|
onChange={e => setScriptUploadSeason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor="script-file">Word Document (.docx)</label>
|
<label htmlFor="script-file">Word Document (.docx)</label>
|
||||||
<input
|
<input
|
||||||
@@ -5247,10 +5290,31 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
{/* Indexed scripts list */}
|
{/* Indexed scripts list */}
|
||||||
<div className="admin-panel-head" style={{ marginTop: '2rem' }}>
|
<div className="admin-panel-head" style={{ marginTop: '2rem' }}>
|
||||||
<h3 style={{ margin: 0 }}>Indexed Scripts</h3>
|
<h3 style={{ margin: 0 }}>Indexed Scripts</h3>
|
||||||
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading} style={{ marginLeft: 'auto' }}>
|
{scriptsList.length > 0 && (() => {
|
||||||
|
const seasons = Array.from(new Set(scriptsList.map(s => s.season).filter((s): s is string => Boolean(s)))).sort((a, b) => Number(a) - Number(b))
|
||||||
|
return seasons.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={scriptSeasonFilter}
|
||||||
|
onChange={e => setScriptSeasonFilter(e.target.value)}
|
||||||
|
style={{ marginLeft: 'auto', marginRight: '0.75rem' }}
|
||||||
|
>
|
||||||
|
<option value="all">All Seasons</option>
|
||||||
|
{seasons.map(s => <option key={s} value={s}>Season {s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={handleBackfillSeasons} disabled={scriptsLoading} style={{ marginLeft: scriptsList.length === 0 ? 'auto' : undefined, marginRight: '0.5rem' }}>
|
||||||
|
Backfill Seasons from RSS
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading}>
|
||||||
{scriptsLoading ? 'Loading…' : 'Refresh'}
|
{scriptsLoading ? 'Loading…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{scriptBackfillMsg && (
|
||||||
|
<p style={{ fontSize: '0.85rem', color: scriptBackfillMsg.startsWith('✓') ? '#6fcf97' : '#e07a7a', marginTop: '0.5rem' }}>
|
||||||
|
{scriptBackfillMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{scriptsList.length === 0 ? (
|
{scriptsList.length === 0 ? (
|
||||||
<p style={{ color: '#5a5440', fontSize: '0.9rem', marginTop: '0.75rem' }}>
|
<p style={{ color: '#5a5440', fontSize: '0.9rem', marginTop: '0.75rem' }}>
|
||||||
No scripts uploaded yet.{' '}
|
No scripts uploaded yet.{' '}
|
||||||
@@ -5258,10 +5322,13 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-script-list">
|
<div className="admin-script-list">
|
||||||
{scriptsList.map(script => (
|
{scriptsList
|
||||||
|
.filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter)
|
||||||
|
.map(script => (
|
||||||
<div key={script.episodeNumber} className="admin-script-row">
|
<div key={script.episodeNumber} 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>}
|
||||||
<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>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface ScriptSearchResult {
|
|||||||
episodeNumber: string
|
episodeNumber: string
|
||||||
title: string
|
title: string
|
||||||
filename: string
|
filename: string
|
||||||
|
season: string | null
|
||||||
snippet: string
|
snippet: string
|
||||||
score: number
|
score: number
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,15 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Season number → book/study title, so script results can show "Romans" instead of "Season 3"
|
||||||
|
const seasonTitleMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const book of content.finishedBooks ?? []) {
|
||||||
|
if (book.season != null && book.title) map.set(String(book.season), book.title)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [content.finishedBooks])
|
||||||
|
|
||||||
// Debounced server-side script search — runs whenever query changes
|
// Debounced server-side script search — runs whenever query changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
|
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
|
||||||
@@ -105,20 +115,23 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
fetch(`/api/episode-scripts/search?q=${encodeURIComponent(query.trim())}`)
|
fetch(`/api/episode-scripts/search?q=${encodeURIComponent(query.trim())}`)
|
||||||
.then(r => r.ok ? r.json() : Promise.reject())
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
.then((data: { results?: ScriptSearchResult[] }) => {
|
.then((data: { results?: ScriptSearchResult[] }) => {
|
||||||
const results = (data.results ?? []).map(r => ({
|
const results = (data.results ?? []).map(r => {
|
||||||
|
const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null
|
||||||
|
return {
|
||||||
id: `script-${r.episodeNumber}`,
|
id: `script-${r.episodeNumber}`,
|
||||||
type: 'episode' as const,
|
type: 'episode' as const,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
subtitle: `Episode ${r.episodeNumber} · Script`,
|
subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '),
|
||||||
description: r.snippet,
|
description: r.snippet || undefined,
|
||||||
scriptSnippet: r.snippet,
|
scriptSnippet: r.snippet || undefined,
|
||||||
href: `/episodes`,
|
href: `/episodes`,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
setScriptResults(results)
|
setScriptResults(results)
|
||||||
})
|
})
|
||||||
.catch(() => setScriptResults([]))
|
.catch(() => setScriptResults([]))
|
||||||
}, 200)
|
}, 200)
|
||||||
}, [query])
|
}, [query, seasonTitleMap])
|
||||||
|
|
||||||
// Build the Fuse corpus: episodes + resources + series + Q&A
|
// Build the Fuse corpus: episodes + resources + series + Q&A
|
||||||
const staticItems: SearchResult[] = useMemo(() => {
|
const staticItems: SearchResult[] = useMemo(() => {
|
||||||
@@ -168,14 +181,43 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
const q = query.trim()
|
const q = query.trim()
|
||||||
if (!q || q.length < 2) return []
|
if (!q || q.length < 2) return []
|
||||||
|
|
||||||
const fuseMatches = fuse.search(q).slice(0, 6).map(r => r.item)
|
// Fuse's own score is 0 (perfect) to 1 (worst) — flip it so higher is better,
|
||||||
|
// and comparable to the script-search endpoint's 0–1 "higher is better" score.
|
||||||
|
const fuseMatches = fuse.search(q).slice(0, 10).map(r => ({ item: r.item, score: 1 - (r.score ?? 0) }))
|
||||||
|
|
||||||
// Merge script results — deduplicate by episode id (prefer script match if already in fuse)
|
// Episode/nuggets/script ids all end in "-<episodeNumber>" when a number is known;
|
||||||
const fuseIds = new Set(fuseMatches.map(r => r.id))
|
// use that to merge a title match and a script-content match into one row instead
|
||||||
const dedupedScripts = scriptResults.filter(r => !fuseIds.has(r.id))
|
// of showing the same episode twice.
|
||||||
|
const episodeNumberFromId = (id: string): string | null => {
|
||||||
|
const m = /^(?:episode|nuggets|script)-(\d+)$/.exec(id)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
// Interleave: fuse results first, then script-only hits, total cap 8
|
const merged = [...fuseMatches]
|
||||||
return [...fuseMatches, ...dedupedScripts].slice(0, 8)
|
const indexByEpisode = new Map<string, number>()
|
||||||
|
merged.forEach((m, i) => {
|
||||||
|
const num = episodeNumberFromId(m.item.id)
|
||||||
|
if (num) indexByEpisode.set(num, i)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const scriptItem of scriptResults) {
|
||||||
|
const num = episodeNumberFromId(scriptItem.id)
|
||||||
|
const existingIdx = num ? indexByEpisode.get(num) : undefined
|
||||||
|
if (existingIdx != null) {
|
||||||
|
// Same episode already matched by title/description — attach the script
|
||||||
|
// snippet so the row shows why it matched, and boost its relevance.
|
||||||
|
const existing = merged[existingIdx]
|
||||||
|
merged[existingIdx] = {
|
||||||
|
item: existing.item.scriptSnippet ? existing.item : { ...existing.item, scriptSnippet: scriptItem.scriptSnippet },
|
||||||
|
score: Math.max(existing.score, 0.9),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged.push({ item: scriptItem, score: 0.9 })
|
||||||
|
if (num) indexByEpisode.set(num, merged.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.sort((a, b) => b.score - a.score).slice(0, 8).map(m => m.item)
|
||||||
}, [fuse, query, scriptResults])
|
}, [fuse, query, scriptResults])
|
||||||
|
|
||||||
return { query, setQuery, results }
|
return { query, setQuery, results }
|
||||||
|
|||||||
Reference in new issue
Block a user