feat: add transcripts with audio links
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user