/** * 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(/][\s\S]*?<\/script>/gi, ""); clean = clean.replace(/][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/][\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): 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): 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 { const raw = await bffList<{ book: string; data: Record }>( "/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 { const raw = await bff<{ book: string; data: Record }>( `/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; }): 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 { const path = versePath(book, division, chapter); const res = await bff<{ verses?: Record[]; }>(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 { const path = versePath(book, division, chapter); const raw = await bff<{ verses: Record[]; 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 { const path = versePath(book, division, chapter); const raw = await bff>(`${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 { // 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 = {}; if (type) params.type = type; const chapters = await bffList(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 { const path = book === "kb" && volume > 0 ? `/books/kb/${volume}/${slug}` : `/books/${book}/chapters/${slug}`; const chapter = await bff(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 { 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, })); }