293 lines
8.3 KiB
TypeScript
293 lines
8.3 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;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
};
|
|
}
|