414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
/**
|
|
* Data access layer for server components.
|
|
*
|
|
* These functions call the Go BFF API server instead of querying the DB
|
|
* directly. The BFF handles sanitization, pagination, and caching.
|
|
*/
|
|
|
|
import { bff, bffList } from "@/lib/bff";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type BookMeta = {
|
|
book: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type VerseRecord = {
|
|
id: string;
|
|
book: string;
|
|
division: string | null;
|
|
chapter: number;
|
|
verse: number;
|
|
verseEnd: number | null;
|
|
reference: string;
|
|
bookTitle: string;
|
|
devanagari: string;
|
|
bengali: string | null;
|
|
transliteration: string;
|
|
synonyms: { word: string; meanings: string[] }[];
|
|
translation: string;
|
|
purport: string[];
|
|
/** Alias for bookTitle — used by ISO/BS/NOI pages */
|
|
title?: string;
|
|
/** Alias for verse number — used by NOI pages as "text number" */
|
|
text?: number;
|
|
/** Alias for verse/chapter number — used by ISO pages as "mantra number" */
|
|
mantra?: number;
|
|
};
|
|
|
|
export type VerseIndexEntry = {
|
|
verse: number;
|
|
verseEnd: number | null;
|
|
reference: string;
|
|
};
|
|
|
|
export type PaginatedVerses = {
|
|
verses: VerseRecord[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
export type GlossaryRecord = {
|
|
id: string;
|
|
term: string;
|
|
definition: string;
|
|
definitionHtml: string;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prose chapter types (for KB and other prose books)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ProseChapterRecord = {
|
|
id: string;
|
|
book: string;
|
|
volume: number;
|
|
chapter: number | null;
|
|
title: string;
|
|
type: string;
|
|
slug: string;
|
|
order: number;
|
|
paragraphs: string[];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTML sanitisation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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, "");
|
|
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
return clean;
|
|
}
|
|
|
|
function sanitizeParagraphs(paragraphs: string[] | undefined): string[] {
|
|
if (!paragraphs || !Array.isArray(paragraphs)) return [];
|
|
return paragraphs.map(sanitizeHtml);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a BFF path for verse-related endpoints.
|
|
* Books like BG and SB1962 have no division (null), so we skip it.
|
|
* Books like SB and CC have a division (canto / līlā).
|
|
*/
|
|
function versePath(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
): string {
|
|
if (division) return `/books/${book}/${division}/${chapter}`;
|
|
return `/books/${book}/${chapter}`;
|
|
}
|
|
|
|
/**
|
|
* Map a BFF verse response (snake_case) to the frontend VerseRecord (camelCase).
|
|
*/
|
|
function mapVerse(v: Record<string, unknown>): VerseRecord {
|
|
const bookTitle = String(v.book_title ?? v.bookTitle ?? "");
|
|
const chapter = Number(v.chapter ?? 0);
|
|
const verse = Number(v.verse ?? 0);
|
|
return {
|
|
id: String(v.id ?? ""),
|
|
book: String(v.book ?? ""),
|
|
division: v.division ? String(v.division) : null,
|
|
chapter,
|
|
verse,
|
|
verseEnd:
|
|
v.verse_end != null
|
|
? Number(v.verse_end)
|
|
: v.verseEnd != null
|
|
? Number(v.verseEnd)
|
|
: null,
|
|
reference: String(v.reference ?? ""),
|
|
bookTitle,
|
|
devanagari: String(v.devanagari ?? ""),
|
|
bengali: v.bengali ? String(v.bengali) : null,
|
|
transliteration: String(v.transliteration ?? ""),
|
|
synonyms: (v.synonyms as { word: string; meanings: string[] }[]) ?? [],
|
|
translation: String(v.translation ?? ""),
|
|
purport: (v.purport as string[]) ?? [],
|
|
// Aliases for page backwards compatibility
|
|
title: bookTitle,
|
|
text: verse,
|
|
mantra: chapter,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a BFF verse-index entry (snake_case) to VerseIndexEntry (camelCase).
|
|
*/
|
|
function mapVerseIndex(v: Record<string, unknown>): VerseIndexEntry {
|
|
return {
|
|
verse: Number(v.verse ?? 0),
|
|
verseEnd: v.verse_end ? Number(v.verse_end) : null,
|
|
reference: String(v.reference ?? ""),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Book meta
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get all book metas — each has { book, ...data } where data is the intro.json blob.
|
|
*
|
|
* The BFF returns { id, book, data: { title, author, chapters, ... } }.
|
|
* We flatten it to { book, title, author, chapters, ... } for the frontend.
|
|
*/
|
|
export async function getAllBooks(): Promise<BookMeta[]> {
|
|
const raw = await bffList<{ book: string; data: Record<string, unknown> }>(
|
|
"/books",
|
|
{ tags: ["books"] },
|
|
);
|
|
return raw.map(flattenBookMeta);
|
|
}
|
|
|
|
/**
|
|
* Get a single book's meta by slug ("bg", "sb", "cc").
|
|
*/
|
|
export async function getBookMeta(book: string): Promise<BookMeta | null> {
|
|
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
|
|
`/books/${book}`,
|
|
{ tags: [`book-${book}`] },
|
|
);
|
|
if (!raw) return null;
|
|
return flattenBookMeta(raw);
|
|
}
|
|
|
|
/**
|
|
* Flatten a BFF book meta response.
|
|
* BFF returns: { id, book, data: { title, author, chapters, ... } }
|
|
* Frontend expects: { book, title, author, chapters, ... }
|
|
*/
|
|
function flattenBookMeta(raw: {
|
|
book: string;
|
|
data: Record<string, unknown>;
|
|
}): BookMeta {
|
|
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
|
|
return { book: raw.book, ...data } as BookMeta;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Verse index (lightweight — for sidebar)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get the verse index for a chapter — returns only verse number, verseEnd,
|
|
* and reference for every verse. This is lightweight enough to fetch all at
|
|
* once even for chapters with 400+ verses (each entry is ~50 bytes).
|
|
*
|
|
* - BG: division is null
|
|
* - SB: division is canto number as string ("1", "2", …)
|
|
* - CC: division is līlā key ("adi", "madhya", "antya")
|
|
*/
|
|
export async function getChapterVerseIndex(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
): Promise<VerseIndexEntry[]> {
|
|
const path = versePath(book, division, chapter);
|
|
const res = await bff<{
|
|
verses?: Record<string, unknown>[];
|
|
}>(path, {
|
|
tags: [`verses-${book}`],
|
|
});
|
|
if (!res || !res.verses) return [];
|
|
return res.verses.map(mapVerseIndex);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Verses (paginated)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
/**
|
|
* Get a page of verses for a given book + division + chapter.
|
|
*
|
|
* Returns the verses for the requested page along with pagination metadata
|
|
* so the client knows whether to fetch more.
|
|
*/
|
|
export async function getChapterVerses(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
page: number = 1,
|
|
limit: number = DEFAULT_PAGE_SIZE,
|
|
): Promise<PaginatedVerses> {
|
|
const path = versePath(book, division, chapter);
|
|
|
|
const raw = await bff<{
|
|
verses: Record<string, unknown>[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
hasMore: boolean;
|
|
}>(`${path}/verses`, {
|
|
params: {
|
|
page: String(page),
|
|
limit: String(limit),
|
|
},
|
|
tags: [`verses-${book}`],
|
|
});
|
|
|
|
if (!raw) {
|
|
return {
|
|
verses: [],
|
|
total: 0,
|
|
page,
|
|
limit,
|
|
totalPages: 0,
|
|
hasMore: false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
verses: (raw.verses ?? []).map(mapVerse),
|
|
total: raw.total,
|
|
page: raw.page,
|
|
limit: raw.limit,
|
|
totalPages: raw.totalPages,
|
|
hasMore: raw.hasMore,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single verse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get a single verse by its exact coordinates.
|
|
*/
|
|
export async function getSingleVerse(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
verse: number,
|
|
): Promise<VerseRecord | null> {
|
|
const path = versePath(book, division, chapter);
|
|
const raw = await bff<Record<string, unknown>>(`${path}/${verse}`, {
|
|
tags: [`verses-${book}`],
|
|
});
|
|
return raw ? mapVerse(raw) : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prose chapters (KB and other prose books)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get all prose chapters/segments for a given book and volume,
|
|
* optionally filtered by type ("chapter" | "intro-segment").
|
|
* Ordered by the `order` field.
|
|
*
|
|
* For KB (which has real volumes), uses /api/v1/books/kb/{volume}.
|
|
* For other books (volume=0 sentinel), uses /api/v1/books/{code}/chapters.
|
|
*/
|
|
export async function getProseChapters(
|
|
book: string,
|
|
volume: number,
|
|
type?: string,
|
|
): Promise<ProseChapterRecord[]> {
|
|
// KB has real volumes (1, 2, 3, …); other prose books use volume=0 as sentinel
|
|
const path =
|
|
book === "kb" && volume > 0
|
|
? `/books/kb/${volume}`
|
|
: `/books/${book}/chapters`;
|
|
|
|
const params: Record<string, string> = {};
|
|
if (type) params.type = type;
|
|
|
|
const chapters = await bffList<ProseChapterRecord>(path, {
|
|
params: Object.keys(params).length > 0 ? params : undefined,
|
|
tags: [`book-${book}`],
|
|
});
|
|
return chapters.map((ch) => ({
|
|
...ch,
|
|
paragraphs: sanitizeParagraphs(ch.paragraphs),
|
|
id: ch.id ?? "",
|
|
book: ch.book ?? book,
|
|
volume: ch.volume ?? volume,
|
|
chapter: ch.chapter ?? null,
|
|
title: ch.title ?? "",
|
|
type: ch.type ?? "chapter",
|
|
slug: ch.slug ?? "",
|
|
order: ch.order ?? 0,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get a single prose chapter by book + volume + slug.
|
|
*
|
|
* For KB, uses /api/v1/books/kb/{volume}/{slug}.
|
|
* For other books, uses /api/v1/books/{code}/chapters/{slug}.
|
|
*/
|
|
export async function getProseChapter(
|
|
book: string,
|
|
volume: number,
|
|
slug: string,
|
|
): Promise<ProseChapterRecord | null> {
|
|
const path =
|
|
book === "kb" && volume > 0
|
|
? `/books/kb/${volume}/${slug}`
|
|
: `/books/${book}/chapters/${slug}`;
|
|
|
|
const chapter = await bff<ProseChapterRecord>(path, {
|
|
tags: [`book-${book}`],
|
|
});
|
|
if (!chapter) return null;
|
|
return {
|
|
...chapter,
|
|
paragraphs: sanitizeParagraphs(chapter.paragraphs),
|
|
id: chapter.id ?? "",
|
|
book: chapter.book ?? book,
|
|
volume: chapter.volume ?? volume,
|
|
chapter: chapter.chapter ?? null,
|
|
title: chapter.title ?? "",
|
|
type: chapter.type ?? "chapter",
|
|
slug: chapter.slug ?? slug,
|
|
order: chapter.order ?? 0,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Glossary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get all glossary entries, ordered alphabetically by term.
|
|
*/
|
|
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
|
const raw = await bffList<{
|
|
id: string;
|
|
term: string;
|
|
definition: string;
|
|
definition_html: string;
|
|
}>("/glossary", { tags: ["glossary"] });
|
|
|
|
return raw.map((entry) => ({
|
|
id: entry.id,
|
|
term: entry.term,
|
|
definition: entry.definition,
|
|
definitionHtml: entry.definition_html ?? entry.definition,
|
|
}));
|
|
}
|