/** * Data access layer for Other Documents. * * Calls the BFF API for all data access (no direct Prisma usage). * Falls back to reading local JSON files from * lib/jsons/other-documents// when the BFF is unavailable. * * Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula"). */ import { bff, bffList } from "@/lib/bff"; import fs from "fs"; import path from "path"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const LOCAL_JSON_DIR = path.join( process.cwd(), "lib", "jsons", "other-documents", ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type OtherDocumentMetaData = { book: string; title: string; author: string; year: number | null; description: string | null; totalChapters: number; chapters: OtherDocumentChapterEntry[]; }; export type OtherDocumentChapterEntry = { number: number; title: string; }; export type OtherDocumentChapterData = { book: string; chapter: number; title: string; type: string; paragraphs: string[]; }; /** Lightweight summary for the top-level index page. */ export type OtherDocumentSummary = { book: string; title: string; author: string; year: number | null; description: string | null; totalChapters: number; }; /** Used in sidebar / table-of-contents navigation. */ export type OtherDocumentSidebarItem = { slug: string; label: string; type: string; chapter: number; }; // --------------------------------------------------------------------------- // BFF response types (snake_case from Go) // --------------------------------------------------------------------------- type BffDocumentMeta = { id: string; book: string; data: { title: string; author: string; year?: number | null; description?: string | null; totalChapters: number; chapters: { number: number; title: string }[]; }; }; type BffChapter = { id: string; book: string; chapter: number; title: string; type: string; slug: string; paragraphs: string[]; }; // --------------------------------------------------------------------------- // HTML sanitisation (same pattern used across all readers) // --------------------------------------------------------------------------- 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; } // --------------------------------------------------------------------------- // Mappers (BFF response → frontend types) // --------------------------------------------------------------------------- function mapMetaData(raw: BffDocumentMeta): OtherDocumentMetaData { const data = raw.data && typeof raw.data === "object" ? raw.data : ({} as BffDocumentMeta["data"]); return { book: raw.book, title: data.title ?? "", author: data.author ?? "", year: data.year ?? null, description: data.description ?? null, totalChapters: data.totalChapters ?? 0, chapters: (data.chapters ?? []).map((ch) => ({ number: ch.number, title: ch.title, })), }; } function mapSummary(raw: BffDocumentMeta): OtherDocumentSummary { const data = raw.data && typeof raw.data === "object" ? raw.data : ({} as BffDocumentMeta["data"]); return { book: raw.book, title: data.title ?? "", author: data.author ?? "", year: data.year ?? null, description: data.description ?? null, totalChapters: data.totalChapters ?? 0, }; } function mapChapter(raw: BffChapter): OtherDocumentChapterData { return { book: raw.book, chapter: raw.chapter, title: raw.title, type: raw.type, paragraphs: (raw.paragraphs ?? []).map(sanitizeHtml), }; } // --------------------------------------------------------------------------- // Helpers: local JSON fallback // --------------------------------------------------------------------------- function findLocalDirForBook(bookCode: string): string | null { if (!fs.existsSync(LOCAL_JSON_DIR)) return null; const entries = fs.readdirSync(LOCAL_JSON_DIR); for (const entry of entries) { const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json"); if (fs.existsSync(introPath)) { try { const intro = JSON.parse(fs.readFileSync(introPath, "utf-8")); if (intro.book === bookCode) return entry; } catch { // skip } } } return null; } function readLocalJson(filePath: string): T | null { try { if (!fs.existsSync(filePath)) return null; return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; } catch { return null; } } function getAllLocalMetas(): OtherDocumentMetaData[] { if (!fs.existsSync(LOCAL_JSON_DIR)) return []; const entries = fs.readdirSync(LOCAL_JSON_DIR).sort(); const metas: OtherDocumentMetaData[] = []; for (const entry of entries) { const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json"); const intro = readLocalJson(introPath); if (intro) metas.push(intro); } return metas; } function getLocalMeta(bookCode: string): OtherDocumentMetaData | null { const dir = findLocalDirForBook(bookCode); if (!dir) return null; return readLocalJson( path.join(LOCAL_JSON_DIR, dir, "intro.json"), ); } function getLocalChapter( bookCode: string, chapterNum: number, ): OtherDocumentChapterData | null { const dir = findLocalDirForBook(bookCode); if (!dir) return null; const filePath = path.join( LOCAL_JSON_DIR, dir, "chapters", String(chapterNum).padStart(2, "0") + ".json", ); const data = readLocalJson(filePath); if (!data) return null; return { ...data, paragraphs: data.paragraphs.map(sanitizeHtml), }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * List all other documents (lightweight summaries for the index page). */ export async function getAllOtherDocuments(): Promise { try { const results = await bffList("/other-documents", { tags: ["other-documents"], }); if (results.length > 0) { return results.map(mapSummary); } } catch { // fall through to local JSON } const localMetas = getAllLocalMetas(); return localMetas.map((meta) => ({ book: meta.book, title: meta.title, author: meta.author, year: meta.year ?? null, description: meta.description ?? null, totalChapters: meta.totalChapters, })); } /** * Get metadata for a single other document by book code. */ export async function getOtherDocumentMeta( bookCode: string, ): Promise { try { const raw = await bff(`/other-documents/${bookCode}`, { tags: [`other-doc-${bookCode}`], }); if (raw) { return mapMetaData(raw); } } catch { // fall through to local JSON } return getLocalMeta(bookCode); } /** * Get a single chapter from an other document. */ export async function getOtherDocumentChapter( bookCode: string, chapterNum: number, ): Promise { if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null; try { const raw = await bff( `/other-documents/${bookCode}/chapters/${chapterNum}`, { tags: [`other-doc-${bookCode}`] }, ); if (raw) { return mapChapter(raw); } } catch { // fall through to local JSON } return getLocalChapter(bookCode, chapterNum); } /** * Unified content accessor — get content by URL slug. */ export async function getOtherDocumentContent( bookCode: string, slug: string, ): Promise { const num = Number(slug); if (Number.isNaN(num) || !Number.isInteger(num) || num < 1) return null; return getOtherDocumentChapter(bookCode, num); } /** * Build the sidebar navigation list for an other document. */ export async function getOtherDocumentSidebarItems( bookCode: string, ): Promise { const meta = await getOtherDocumentMeta(bookCode); if (!meta) return []; return meta.chapters.map((ch) => ({ slug: String(ch.number), label: `${ch.number}. ${ch.title}`, type: "chapter", chapter: ch.number, })); } /** * Derive the URL slug from a book code. */ export function bookCodeToSlug(bookCode: string): string { return bookCode; } /** * Derive the book code from a URL slug. */ export function slugToBookCode(slug: string): string { return slug; }