/** * Data access layer for Journals. * * Uses the BFF API instead of direct Prisma queries. * Falls back to local JSON files when BFF is unavailable. */ import { bff, bffList } from "@/lib/bff"; import fs from "fs"; import path from "path"; const LOCAL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "journals"); // Types export type JournalMetaData = { book: string; title: string; author: string; year: number | null; description: string | null; totalChapters: number; chapters: JournalChapterEntry[]; }; export type JournalSubChapter = { slug: string; title: string }; export type JournalMonthGroup = { title: string; subChapters: JournalSubChapter[]; }; export type JournalChapterGroup = { title: string; subChapters: (JournalSubChapter | JournalMonthGroup)[]; }; export type JournalChapterEntry = | JournalSubChapter | JournalMonthGroup | JournalChapterGroup; export type JournalChapterData = { book: string; chapter: number; title: string; type: string; paragraphs: string[]; }; export type JournalSummary = { book: string; title: string; author: string; year: number | null; description: string | null; totalChapters: number; }; export type JournalSidebarItem = { slug: string; label: string; type: string; chapter: number; }; // --- BFF response types --- type BffJournalMeta = { id: string; book: string; data: { title: string; author: string; year: number | null; description: string | null; totalChapters: number; chapters: JournalChapterEntry[]; }; }; type BffJournalChapter = { book: string; chapter: number; title: string; type: string; slug: string; paragraphs: string[]; paragraphs_json?: 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 normalizeParagraphs(paragraphs: string[] | unknown): string[] { let result: string[]; if (typeof paragraphs === "string") { try { const parsed = JSON.parse(paragraphs); result = Array.isArray(parsed) ? parsed : [paragraphs]; } catch { result = [paragraphs]; } } else if (Array.isArray(paragraphs)) { result = paragraphs as string[]; } else { result = []; } return result.map(sanitizeHtml); } // --- BFF → domain mappers --- function mapBffMetaToJournalMetaData(raw: BffJournalMeta): JournalMetaData { return { book: raw.book, title: raw.data.title, author: raw.data.author, year: raw.data.year ?? null, description: raw.data.description ?? null, totalChapters: raw.data.totalChapters, chapters: raw.data.chapters, }; } function mapBffMetaToSummary(raw: BffJournalMeta): JournalSummary { return { book: raw.book, title: raw.data.title, author: raw.data.author, year: raw.data.year ?? null, description: raw.data.description ?? null, totalChapters: raw.data.totalChapters, }; } function mapBffChapter(raw: BffJournalChapter): JournalChapterData { // Handle paragraphs_json fallback let paragraphs = raw.paragraphs; if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) { try { paragraphs = JSON.parse(raw.paragraphs_json); } catch { paragraphs = []; } } return { book: raw.book, chapter: raw.chapter, title: raw.title, type: raw.type, paragraphs: normalizeParagraphs(paragraphs), }; } // Local JSON fallback helpers 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(): JournalMetaData[] { if (!fs.existsSync(LOCAL_JSON_DIR)) return []; const entries = fs.readdirSync(LOCAL_JSON_DIR).sort(); const metas: JournalMetaData[] = []; 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): JournalMetaData | null { const dir = findLocalDirForBook(bookCode); if (!dir) return null; return readLocalJson( path.join(LOCAL_JSON_DIR, dir, "intro.json"), ); } function getLocalChapterBySlug( bookCode: string, slug: string, ): JournalChapterData | null { const dir = findLocalDirForBook(bookCode); if (!dir) return null; // Try slug directly as filename (e.g. "preface.json", "apr-3.json") const filePath = path.join(LOCAL_JSON_DIR, dir, "chapters", `${slug}.json`); const data = readLocalJson(filePath); if (!data) return null; return { ...data, paragraphs: data.paragraphs.map(sanitizeHtml) }; } // Public API export async function getAllJournals(): Promise { try { const results = await bffList("/journals"); if (results.length > 0) { return results.map(mapBffMetaToSummary); } } catch { /* fall through */ } 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, })); } export async function getJournalMeta( bookCode: string, ): Promise { try { const raw = await bff(`/journals/${bookCode}`); if (raw) return mapBffMetaToJournalMetaData(raw); } catch { /* fall through */ } return getLocalMeta(bookCode); } export async function getJournalChapterBySlug( bookCode: string, slug: string, ): Promise { if (!slug) return null; try { const raw = await bff( `/journals/${bookCode}/chapters/${slug}`, ); if (raw) return mapBffChapter(raw); } catch { /* fall through */ } return getLocalChapterBySlug(bookCode, slug); } export async function getJournalContent( bookCode: string, slug: string, ): Promise { return getJournalChapterBySlug(bookCode, slug); } function isSubChapter(ch: JournalChapterEntry): ch is JournalSubChapter { return "slug" in ch && !("subChapters" in ch); } function hasSubChapters( ch: JournalChapterEntry, ): ch is JournalMonthGroup | JournalChapterGroup { return "subChapters" in ch && Array.isArray(ch.subChapters); } export async function getJournalSidebarItems( bookCode: string, ): Promise { const meta = await getJournalMeta(bookCode); if (!meta) return []; const items: JournalSidebarItem[] = []; function addEntry(entry: JournalChapterEntry) { if (isSubChapter(entry)) { items.push({ slug: entry.slug, label: entry.title, type: "chapter", chapter: 0, }); } else if (hasSubChapters(entry)) { // Group heading (place name or month name) items.push({ slug: "", label: entry.title, type: "heading", chapter: 0, }); for (const sc of entry.subChapters) { addEntry(sc); } } } for (const ch of meta.chapters) { addEntry(ch); } return items; } export function bookCodeToSlug(bookCode: string): string { return bookCode; } export function slugToBookCode(slug: string): string { return slug; }