/** * Data access layer for Other Documents. * * Uses dedicated Prisma models: * - OtherDocumentMeta (collection: other_document_meta) * - OtherDocumentChapter (collection: other_document_chapters) * * Falls back to reading local JSON files from * lib/jsons/other-documents// when the database is unavailable. * * Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula"). */ import { prisma } from "@/lib/db"; import type { JsonValue } from "@prisma/client/runtime/library"; 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; }; // --------------------------------------------------------------------------- // 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; } function normalizeParagraphs(paragraphs: string[] | JsonValue): 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); } // --------------------------------------------------------------------------- // 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 prisma.otherDocumentMeta.findMany({ orderBy: { book: "asc" }, }); if (results.length > 0) { return results.map((row) => { const data = row.data as unknown as OtherDocumentMetaData; return { book: row.book, title: data.title, author: data.author, year: data.year ?? null, description: data.description ?? null, totalChapters: data.totalChapters, }; }); } } 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 row = await prisma.otherDocumentMeta.findUnique({ where: { book: bookCode }, }); if (row) { return row.data as unknown as OtherDocumentMetaData; } } 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 doc = await prisma.otherDocumentChapter.findUnique({ where: { book_slug: { book: bookCode, slug: String(chapterNum), }, }, }); if (doc) { return { book: doc.book, chapter: doc.chapter, title: doc.title, type: doc.type, paragraphs: normalizeParagraphs(doc.paragraphs), }; } } 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; }