/** * Data access layer for Transcriptions. * * Uses Prisma to query the `transcriptions` collection in MongoDB. * Replaces the previous local-JSON-file approach so that data is always * served from the database (seeded via seed-transcriptions.ts). * * Some documents store their content as a JSON string in `contentJson` * (a BSON wire-protocol workaround for very large nested arrays). * The `getTranscription()` helper transparently parses that fallback. */ import { prisma } from "@/lib/db"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type TranscriptionSummary = { slug: string; name: string; type: string; date: string | null; year: number | null; month: number | null; day: number | null; place: string | null; context: string | null; reference: string | null; durationMinutes: number | null; participants: string[]; audioUrl: string | null; }; export type ContentBlock = { text: string; format: string; alignment: string; speaker: string | null; }; export type TranscriptionFull = TranscriptionSummary & { content: ContentBlock[]; }; export type YearGroup = { year: number; count: number; }; export type PlaceGroup = { name: string; count: number; }; // --------------------------------------------------------------------------- // Prisma select objects (keep queries lightweight) // --------------------------------------------------------------------------- /** Fields needed for summary views (no content). */ const SUMMARY_SELECT = { slug: true, name: true, type: true, date: true, year: true, month: true, day: true, place: true, context: true, reference: true, durationMinutes: true, participants: true, audioUrl: true, } as const; // --------------------------------------------------------------------------- // Sanitize // --------------------------------------------------------------------------- function sanitizeHtml(html: string): string { let clean = html.replace(/][\s\S]*?<\/script>/gi, ""); clean = clean.replace(/][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/][\s\S]*?<\/iframe>/gi, ""); clean = clean.replace( /<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi, "", ); clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); return clean; } // --------------------------------------------------------------------------- // Index / overview queries // --------------------------------------------------------------------------- export async function getTranscriptionYears(): Promise { const transcriptions = await prisma.transcription.findMany({ where: { year: { not: null }, isPublished: true }, select: { year: true }, }); const counts: Record = {}; for (const t of transcriptions) { if (t.year) { counts[t.year] = (counts[t.year] || 0) + 1; } } return Object.entries(counts) .map(([year, count]) => ({ year: Number(year), count })) .filter((g) => g.year >= 1960 && g.year <= 1980) .sort((a, b) => a.year - b.year); } export async function getTranscriptionPlaces(): Promise { const transcriptions = await prisma.transcription.findMany({ where: { place: { not: null }, isPublished: true }, select: { place: true }, }); const counts: Record = {}; for (const t of transcriptions) { if (t.place) { counts[t.place] = (counts[t.place] || 0) + 1; } } return Object.entries(counts) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); } export async function getTotalTranscriptionCount(): Promise { return prisma.transcription.count({ where: { isPublished: true } }); } /** * Return a lightweight index object with type distribution counts. * Used by the index page to show lecture/conversation totals. */ export async function getTranscriptionIndex(): Promise<{ totalTranscriptions: number; typeDistribution: Record; }> { const [total, lectures, conversations] = await Promise.all([ prisma.transcription.count({ where: { isPublished: true } }), prisma.transcription.count({ where: { isPublished: true, type: "lecture" }, }), prisma.transcription.count({ where: { isPublished: true, type: "conversation" }, }), ]); return { totalTranscriptions: total, typeDistribution: { lecture: lectures, conversation: conversations, }, }; } // --------------------------------------------------------------------------- // List queries (return summaries without content) // --------------------------------------------------------------------------- export async function getTranscriptionsByYear( year: number, ): Promise { const transcriptions = await prisma.transcription.findMany({ where: { year, isPublished: true }, orderBy: [{ month: "asc" }, { day: "asc" }], select: SUMMARY_SELECT, }); return transcriptions; } export async function getTranscriptionsByPlace( place: string, ): Promise { const transcriptions = await prisma.transcription.findMany({ where: { place, isPublished: true }, orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }], select: SUMMARY_SELECT, }); return transcriptions; } export async function getTranscriptionsByType( type: string, ): Promise { const transcriptions = await prisma.transcription.findMany({ where: { type, isPublished: true }, orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }], select: SUMMARY_SELECT, }); return transcriptions; } // --------------------------------------------------------------------------- // Single transcription // --------------------------------------------------------------------------- export async function getTranscription( slug: string, ): Promise { const t = await prisma.transcription.findUnique({ where: { slug } }); if (!t || !t.isPublished) return null; // Content may be stored as embedded array or as a JSON string fallback let content: ContentBlock[] = []; if (t.content && t.content.length > 0) { content = t.content.map((block) => ({ text: sanitizeHtml(block.text), format: block.format, alignment: block.alignment, speaker: block.speaker ?? null, })); } else { // Check for contentJson fallback (BSON workaround for large documents) const raw = t as unknown as Record; const contentJson = raw.contentJson; if (typeof contentJson === "string" && contentJson.length > 2) { try { const parsed = JSON.parse(contentJson) as ContentBlock[]; content = parsed.map((block) => ({ text: sanitizeHtml(block.text), format: block.format, alignment: block.alignment, speaker: block.speaker ?? null, })); } catch { // Ignore parse errors — return empty content } } } return { slug: t.slug, name: t.name, type: t.type, date: t.date, year: t.year, month: t.month, day: t.day, place: t.place, context: t.context, reference: t.reference, durationMinutes: t.durationMinutes, participants: t.participants, audioUrl: t.audioUrl, content, }; } // --------------------------------------------------------------------------- // Navigation helpers // --------------------------------------------------------------------------- export async function getAdjacentTranscriptions( slug: string, year: number | null, ): Promise<{ prev: TranscriptionSummary | null; next: TranscriptionSummary | null; }> { if (!year) return { prev: null, next: null }; const yearTranscriptions = await prisma.transcription.findMany({ where: { year, isPublished: true }, orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }], select: SUMMARY_SELECT, }); const idx = yearTranscriptions.findIndex((t) => t.slug === slug); return { prev: idx > 0 ? yearTranscriptions[idx - 1] : null, next: idx < yearTranscriptions.length - 1 ? yearTranscriptions[idx + 1] : null, }; }