Optimize speech search and admin payloads
Some checks failed
/ deploy (push) Has been cancelled

This commit is contained in:
Loyyd 2026-07-05 14:37:47 +02:00
parent b1ebe2ad57
commit 561b28bebf
6 changed files with 223 additions and 38 deletions

52
src/lib/admin-speeches.ts Normal file
View file

@ -0,0 +1,52 @@
import type { ArchiveEntry } from "./archive";
export type SpeechAdminEntry = {
id: string;
path: string;
title: string;
publishedDate: string;
speechYear: string;
speaker: "rev" | "mrs" | "other";
source: string;
categories: string[];
tags: string[];
bodyUrl: string;
};
function encodePath(value: string): string {
return value.split("/").map(encodeURIComponent).join("/");
}
export function speechAdminEntryFromArchiveEntry(entry: ArchiveEntry, fallbackDate: string): SpeechAdminEntry {
const categories = entry.data.categories ?? [];
const tags = entry.data.tags ?? [];
const publishedDate = entry.data.published?.slice(0, 10) ?? fallbackDate;
const speechYear = entry.data.speechYear ?? entry.id.match(/\b(1\d{3}|20\d{2})\b/)?.[1] ?? new Date().getFullYear().toString();
const categoryText = categories.join(" ").toLowerCase();
const collection = entry.data.speechCollection ?? "";
const speaker = categoryText.includes("hak ja han") || collection === "mrs-hak-ja-han-moon"
? "mrs"
: categoryText.includes("rev sun myung moon") || /^\d{4}$/.test(collection) || /^\d{4}$/.test(speechYear)
? "rev"
: "other";
return {
id: entry.id,
path: `content/${entry.id}.md`,
title: entry.data.title ?? entry.id,
publishedDate,
speechYear,
speaker,
source: entry.data.source ?? "",
categories,
tags,
bodyUrl: `/admin/content/${encodePath(entry.id)}.json`,
};
}
export function speechAdminEntryWithBody(entry: ArchiveEntry, fallbackDate: string) {
return {
...speechAdminEntryFromArchiveEntry(entry, fallbackDate),
body: entry.body ?? "",
};
}

View file

@ -32,6 +32,14 @@ export type SpeechArchiveItem = {
searchText: string; searchText: string;
}; };
export type SpeechSearchIndexItem = Omit<SpeechArchiveItem, "searchText">;
export type CompactSpeechSearchIndex = {
version: 2;
items: SpeechSearchIndexItem[];
index: Record<string, string>;
};
const entityMap: Record<string, string> = { const entityMap: Record<string, string> = {
amp: "&", amp: "&",
apos: "'", apos: "'",
@ -215,6 +223,39 @@ export function getSpeechArchive(): SpeechArchiveItem[] {
}); });
} }
function packDocumentIds(documentIds: number[]): string {
return documentIds.map((documentId) => documentId.toString(36)).join(",");
}
export function getCompactSpeechSearchIndex(): CompactSpeechSearchIndex {
const items = getSpeechArchive();
const index = new Map<string, number[]>();
items.forEach((item, documentId) => {
const terms = new Set(item.searchText.match(/[a-z0-9]+/g) ?? []);
terms.forEach((term) => {
if (term.length < 2) {
return;
}
const documentIds = index.get(term) ?? [];
documentIds.push(documentId);
index.set(term, documentIds);
});
});
return {
version: 2,
items: items.map(({ searchText, ...item }) => item),
index: Object.fromEntries(
Array.from(index)
.sort(([a], [b]) => a.localeCompare(b))
.map(([term, documentIds]) => [term, packDocumentIds(documentIds)]),
),
};
}
export function uniqueSorted(values: string[], direction: "asc" | "desc" = "asc"): string[] { export function uniqueSorted(values: string[], direction: "asc" | "desc" = "asc"): string[] {
const unique = Array.from(new Set(values.filter(Boolean))); const unique = Array.from(new Set(values.filter(Boolean)));
return unique.sort((a, b) => { return unique.sort((a, b) => {

View file

@ -0,0 +1,23 @@
import { getArchiveEntries } from "../../../lib/archive";
import { speechAdminEntryWithBody } from "../../../lib/admin-speeches";
export async function getStaticPaths() {
const today = new Date().toISOString().slice(0, 10);
const entries = await getArchiveEntries("speech");
return entries.map((entry) => ({
params: { id: entry.id },
props: {
speech: speechAdminEntryWithBody(entry, today),
},
}));
}
export function GET({ props }: { props: { speech: ReturnType<typeof speechAdminEntryWithBody> } }) {
return new Response(JSON.stringify(props.speech), {
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "public, max-age=300",
},
});
}

View file

@ -1,5 +1,6 @@
--- ---
import { getArchiveEntries } from "../../lib/archive"; import { getArchiveEntries } from "../../lib/archive";
import { speechAdminEntryFromArchiveEntry } from "../../lib/admin-speeches";
import { calendarEvents, recurringCalendarEvents } from "../../data/events"; import { calendarEvents, recurringCalendarEvents } from "../../data/events";
const entries = await getArchiveEntries(); const entries = await getArchiveEntries();
@ -17,32 +18,7 @@ const latestEntryDate = latestEntry?.data.published
: "No date"; : "No date";
const totalEvents = calendarEvents.length + recurringCalendarEvents.length; const totalEvents = calendarEvents.length + recurringCalendarEvents.length;
const configuredServices = [contactFormEndpoint, plausibleDomain].filter(Boolean).length; const configuredServices = [contactFormEndpoint, plausibleDomain].filter(Boolean).length;
const speechAdminEntries = speechEntries.map((entry) => { const speechAdminEntries = speechEntries.map((entry) => speechAdminEntryFromArchiveEntry(entry, today));
const categories = entry.data.categories ?? [];
const tags = entry.data.tags ?? [];
const publishedDate = entry.data.published?.slice(0, 10) ?? today;
const speechYear = entry.data.speechYear ?? entry.id.match(/\b(1\d{3}|20\d{2})\b/)?.[1] ?? new Date().getFullYear().toString();
const categoryText = categories.join(" ").toLowerCase();
const collection = entry.data.speechCollection ?? "";
const speaker = categoryText.includes("hak ja han") || collection === "mrs-hak-ja-han-moon"
? "mrs"
: categoryText.includes("rev sun myung moon") || /^\d{4}$/.test(collection) || /^\d{4}$/.test(speechYear)
? "rev"
: "other";
return {
id: entry.id,
path: `content/${entry.id}.md`,
title: entry.data.title ?? entry.id,
publishedDate,
speechYear,
speaker,
source: entry.data.source ?? "",
categories,
tags,
body: entry.body ?? "",
};
});
const adminConfig = { const adminConfig = {
analyticsDashboardUrl, analyticsDashboardUrl,
contactEndpointConfigured: Boolean(contactFormEndpoint), contactEndpointConfigured: Boolean(contactFormEndpoint),
@ -1265,11 +1241,29 @@ const adminConfig = {
} }
} }
function loadSpeechEditor(speech) { async function loadSpeechEditor(speech) {
if (!speechForm) { if (!speechForm) {
return; return;
} }
const message = document.querySelector("[data-speech-message]");
setMessage(message, "Loading speech body...");
if (!speech.body && speech.bodyUrl) {
try {
const response = await fetch(speech.bodyUrl);
if (!response.ok) {
throw new Error(`Speech body request failed: ${response.status}`);
}
speech = { ...speech, ...(await response.json()) };
} catch {
setMessage(message, "Could not load that speech body. Try refreshing the admin page.", "error");
return;
}
}
setFormValue(speechForm, "title", speech.title); setFormValue(speechForm, "title", speech.title);
setFormValue(speechForm, "publishedDate", speech.publishedDate); setFormValue(speechForm, "publishedDate", speech.publishedDate);
setFormValue(speechForm, "speechYear", speech.speechYear); setFormValue(speechForm, "speechYear", speech.speechYear);

View file

@ -92,6 +92,9 @@ const speakers = uniqueSorted(speeches.map((speech) => speech.speaker));
} }
let speeches = []; let speeches = [];
let searchIndex = {};
let searchTerms = [];
const termCache = new Map();
let renderTimer = 0; let renderTimer = 0;
function normalize(value) { function normalize(value) {
@ -129,19 +132,82 @@ const speakers = uniqueSorted(speeches.map((speech) => speech.speaker));
speakerSelect.value = params.get("speaker") || ""; speakerSelect.value = params.get("speaker") || "";
} }
function matchesSpeech(speech, filters) { function decodeDocumentIds(value) {
return new Set(String(value || "").split(",").filter(Boolean).map((documentId) => Number.parseInt(documentId, 36)));
}
function unionSets(sets) {
const union = new Set();
sets.forEach((set) => {
set.forEach((value) => union.add(value));
});
return union;
}
function intersectSets(left, right) {
const smaller = left.size <= right.size ? left : right;
const larger = left.size <= right.size ? right : left;
const intersection = new Set();
smaller.forEach((value) => {
if (larger.has(value)) {
intersection.add(value);
}
});
return intersection;
}
function documentsForTerm(term) {
if (termCache.has(term)) {
return termCache.get(term);
}
const matches = [];
if (searchIndex[term]) {
matches.push(decodeDocumentIds(searchIndex[term]));
}
if (term.length >= 3) {
searchTerms.forEach((indexedTerm) => {
if (indexedTerm !== term && indexedTerm.startsWith(term)) {
matches.push(decodeDocumentIds(searchIndex[indexedTerm]));
}
});
}
const documentIds = matches.length ? unionSets(matches) : new Set();
termCache.set(term, documentIds);
return documentIds;
}
function searchCandidates(query) {
const terms = normalize(query).match(/[a-z0-9]+/g)?.filter((term) => term.length >= 2) ?? [];
if (!terms.length) {
return null;
}
return terms.reduce((candidateIds, term) => {
const termIds = documentsForTerm(term);
if (!candidateIds) {
return termIds;
}
return intersectSets(candidateIds, termIds);
}, null);
}
function matchesSpeechMetadata(speech, filters) {
if (filters.year && speech.year !== filters.year) { if (filters.year && speech.year !== filters.year) {
return false; return false;
} }
if (filters.speaker && speech.speaker !== filters.speaker) { if (filters.speaker && speech.speaker !== filters.speaker) {
return false; return false;
} }
if (!filters.q) { return true;
return true;
}
const terms = normalize(filters.q).split(/\s+/).filter(Boolean);
return terms.every((term) => speech.searchText.includes(term));
} }
function appendMeta(parent, values) { function appendMeta(parent, values) {
@ -176,7 +242,13 @@ const speakers = uniqueSorted(speeches.map((speech) => speech.speaker));
function render() { function render() {
const filters = selectedFilters(); const filters = selectedFilters();
const matched = speeches.filter((speech) => matchesSpeech(speech, filters)); const candidateIds = searchCandidates(filters.q);
const matched = speeches.filter((speech, index) => {
if (candidateIds && !candidateIds.has(index)) {
return false;
}
return matchesSpeechMetadata(speech, filters);
});
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
if (matched.length) { if (matched.length) {
@ -208,7 +280,10 @@ const speakers = uniqueSorted(speeches.map((speech) => speech.speaker));
return response.json(); return response.json();
}) })
.then((data) => { .then((data) => {
speeches = data; speeches = Array.isArray(data) ? data : data.items ?? [];
searchIndex = Array.isArray(data) ? {} : data.index ?? {};
searchTerms = Object.keys(searchIndex);
termCache.clear();
render(); render();
form.addEventListener("input", queueRender); form.addEventListener("input", queueRender);
form.addEventListener("change", render); form.addEventListener("change", render);

View file

@ -1,7 +1,7 @@
import { getSpeechArchive } from "../../lib/speeches"; import { getCompactSpeechSearchIndex } from "../../lib/speeches";
export function GET() { export function GET() {
return new Response(JSON.stringify(getSpeechArchive()), { return new Response(JSON.stringify(getCompactSpeechSearchIndex()), {
headers: { headers: {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"cache-control": "public, max-age=300", "cache-control": "public, max-age=300",