Files
spsays-training/lib/transcriptions.ts
T

285 lines
8.2 KiB
TypeScript

/**
* 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(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\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<YearGroup[]> {
const transcriptions = await prisma.transcription.findMany({
where: { year: { not: null }, isPublished: true },
select: { year: true },
});
const counts: Record<number, number> = {};
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<PlaceGroup[]> {
const transcriptions = await prisma.transcription.findMany({
where: { place: { not: null }, isPublished: true },
select: { place: true },
});
const counts: Record<string, number> = {};
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<number> {
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<string, number>;
}> {
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<TranscriptionSummary[]> {
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<TranscriptionSummary[]> {
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<TranscriptionSummary[]> {
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<TranscriptionFull | null> {
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<string, unknown>;
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,
};
}