Files

319 lines
9.0 KiB
TypeScript

/**
* Data access layer for Transcriptions.
*
* Calls the Go BFF API instead of Prisma directly.
* All responses are unwrapped from the BFF envelope by lib/bff.ts.
*
* BFF endpoints used:
* GET /api/v1/transcriptions → { count }
* GET /api/v1/transcriptions/years → [{ _id, count }]
* GET /api/v1/transcriptions/places → [{ _id, count }]
* GET /api/v1/transcriptions/types → { lectures, conversations }
* GET /api/v1/transcriptions/year/{year} → [TranscriptionSummary]
* GET /api/v1/transcriptions/place/{place} → [TranscriptionSummary]
* GET /api/v1/transcriptions/type/{type} → [TranscriptionSummary]
* GET /api/v1/transcriptions/{slug} → TranscriptionFull
* GET /api/v1/transcriptions/adjacent/{slug}?year=N → { prev, next }
*/
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// 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;
};
export type BookGroup = {
code: string;
title: string;
count: number;
};
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffSummary = {
id?: string;
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;
duration_minutes?: number | null;
audio_url?: string | null;
participants?: string[];
};
type BffFull = BffSummary & {
content?: BffContentBlock[];
content_json?: string;
};
type BffContentBlock = {
text: string;
format: string;
alignment: string;
speaker?: string | null;
};
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// Mappers (snake_case BFF → camelCase frontend)
// ---------------------------------------------------------------------------
function mapSummary(s: BffSummary): TranscriptionSummary {
return {
slug: s.slug,
name: s.name,
type: s.type,
date: s.date ?? null,
year: s.year ?? null,
month: s.month ?? null,
day: s.day ?? null,
place: s.place ?? null,
context: s.context ?? null,
reference: s.reference ?? null,
durationMinutes: s.duration_minutes ?? null,
participants: s.participants ?? [],
audioUrl: s.audio_url ?? null,
};
}
function mapContentBlock(b: BffContentBlock): ContentBlock {
return {
text: sanitizeHtml(b.text),
format: b.format,
alignment: b.alignment,
speaker: b.speaker ?? null,
};
}
function mapFull(t: BffFull): TranscriptionFull {
let content: ContentBlock[] = [];
if (t.content && t.content.length > 0) {
content = t.content.map(mapContentBlock);
} else if (t.content_json && t.content_json.length > 2) {
// contentJson fallback (BSON workaround for very large nested arrays)
try {
const parsed = JSON.parse(t.content_json) as BffContentBlock[];
content = parsed.map(mapContentBlock);
} catch {
// Ignore parse errors — return empty content
}
}
return {
...mapSummary(t),
content,
};
}
// ---------------------------------------------------------------------------
// Index / overview queries
// ---------------------------------------------------------------------------
export async function getTranscriptionYears(): Promise<YearGroup[]> {
const raw = await bffList<{ _id: number; count: number }>(
"/transcriptions/years",
{ tags: ["transcriptions"] },
);
return raw
.map((item) => ({ year: item._id, count: item.count }))
.filter((g) => g.year >= 1960 && g.year <= 1980)
.sort((a, b) => a.year - b.year);
}
export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
const raw = await bffList<{ _id: string; count: number }>(
"/transcriptions/places",
{ tags: ["transcriptions"] },
);
return raw
.map((item) => ({ name: item._id, count: item.count }))
.sort((a, b) => b.count - a.count);
}
export async function getTranscriptionBooks(): Promise<BookGroup[]> {
const raw = await bffList<{ _id: string; title: string; count: number }>(
"/transcriptions/books",
{ tags: ["transcriptions"] },
);
return raw
.map((item) => ({ code: item._id, title: item.title, count: item.count }))
.sort((a, b) => b.count - a.count);
}
export async function getTotalTranscriptionCount(): Promise<number> {
const res = await bff<{ count: number }>("/transcriptions", {
tags: ["transcriptions"],
});
return res?.count ?? 0;
}
/**
* 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 [countRes, typesRes] = await Promise.all([
bff<{ count: number }>("/transcriptions", { tags: ["transcriptions"] }),
bff<{ lectures: number; conversations: number }>("/transcriptions/types", {
tags: ["transcriptions"],
}),
]);
return {
totalTranscriptions: countRes?.count ?? 0,
typeDistribution: {
lecture: typesRes?.lectures ?? 0,
conversation: typesRes?.conversations ?? 0,
},
};
}
// ---------------------------------------------------------------------------
// List queries (return summaries without content)
// ---------------------------------------------------------------------------
export async function getTranscriptionsByYear(
year: number,
): Promise<TranscriptionSummary[]> {
const raw = await bffList<BffSummary>(`/transcriptions/year/${year}`, {
tags: ["transcriptions"],
});
return raw.map(mapSummary);
}
export async function getTranscriptionsByPlace(
place: string,
): Promise<TranscriptionSummary[]> {
const encoded = encodeURIComponent(place);
const raw = await bffList<BffSummary>(`/transcriptions/place/${encoded}`, {
tags: ["transcriptions"],
});
return raw.map(mapSummary);
}
export async function getTranscriptionsByType(
type: string,
): Promise<TranscriptionSummary[]> {
const raw = await bffList<BffSummary>(`/transcriptions/type/${type}`, {
tags: ["transcriptions"],
});
return raw.map(mapSummary);
}
export async function getTranscriptionsByBook(
code: string,
): Promise<TranscriptionSummary[]> {
const raw = await bffList<BffSummary>(
`/transcriptions/book/${encodeURIComponent(code)}`,
{ tags: ["transcriptions"] },
);
return raw.map(mapSummary);
}
// ---------------------------------------------------------------------------
// Single transcription
// ---------------------------------------------------------------------------
export async function getTranscription(
slug: string,
): Promise<TranscriptionFull | null> {
const raw = await bff<BffFull>(`/transcriptions/${slug}`, {
tags: ["transcriptions"],
});
if (!raw) return null;
return mapFull(raw);
}
// ---------------------------------------------------------------------------
// 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 res = await bff<{
prev: BffSummary | null;
next: BffSummary | null;
}>(`/transcriptions/adjacent/${slug}`, {
params: { year: String(year) },
tags: ["transcriptions"],
});
if (!res) return { prev: null, next: null };
return {
prev: res.prev ? mapSummary(res.prev) : null,
next: res.next ? mapSummary(res.next) : null,
};
}