/** * Data access layer for Teachings of Lord Kapila (TLK). * * Reads from MongoDB via Prisma, same as the rest of the app. * TLK is a prose book with no volume structure — * we use volume=0 as the sentinel for "no volumes". * * When the DB isn't seeded yet, falls back to reading local JSON files * from lib/jsons/books/tlk/. * * Collections used: * - book_meta (via Prisma BookMeta model) * - prose_chapters (via Prisma ProseChapter model) */ import { prisma } from "@/lib/db"; import type { JsonValue } from "@prisma/client/runtime/library"; import fs from "fs"; import path from "path"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Volume sentinel for books without volumes. */ const NO_VOLUME = 0; const BOOK = "tlk"; const TLK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "tlk"); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type TlkMeta = { book: string; title: string; author: string; year?: number; description: string; totalChapters: number; chapters: TlkChapterEntry[]; }; export type TlkChapterEntry = { number: number; title: string; }; export type TlkIntroSegment = { slug: string; title: string; type: string; book: string; order: number; paragraphs: string[]; }; export type TlkChapter = { book: string; chapter: number | null; title: string; type: string; slug?: string; paragraphs: string[]; }; /** Union type used in sidebar / table-of-contents navigation. */ export type TlkSidebarItem = { /** URL-safe slug: "intro-front-pages", "1", "18", "intro-about-the-author" */ slug: string; /** Human-readable label for the sidebar */ label: string; /** "intro-segment" | "chapter" */ type: string; /** Chapter number, or null for non-chapter items */ chapter: number | null; }; // --------------------------------------------------------------------------- // HTML sanitisation (mirrors lib/api.ts) // --------------------------------------------------------------------------- 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; } /** Normalize paragraphs — handles both native arrays and stringified JSON. */ 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); } // --------------------------------------------------------------------------- // Local JSON helpers (fallback when DB is not seeded) // --------------------------------------------------------------------------- function readLocalJson(filePath: string): T | null { if (!fs.existsSync(filePath)) return null; try { return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; } catch { return null; } } function getLocalIntroSegments(): TlkIntroSegment[] { const introDir = path.join(TLK_JSON_DIR, "intro"); if (!fs.existsSync(introDir)) return []; const results: TlkIntroSegment[] = []; const files = [ "front-pages.json", "foreword.json", "about-the-author.json", "credits.json", "back-flap.json", ]; for (const file of files) { const filePath = path.join(introDir, file); const seg = readLocalJson(filePath); if (seg) results.push(seg); } return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } function getLocalIntroSegment(rawSlug: string): TlkIntroSegment | null { const filePath = path.join(TLK_JSON_DIR, "intro", `${rawSlug}.json`); return readLocalJson(filePath); } function getLocalChapter(chapterNum: number): TlkChapter | null { const filePath = path.join( TLK_JSON_DIR, "chapters", String(chapterNum).padStart(2, "0") + ".json", ); return readLocalJson(filePath); } // --------------------------------------------------------------------------- // Book meta // --------------------------------------------------------------------------- /** * Book-level metadata (title, author, chapter list, etc.) */ export async function getTlkMeta(): Promise { const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); if (meta) return meta.data as unknown as TlkMeta; // Fallback to local JSON const introPath = path.join(TLK_JSON_DIR, "intro.json"); return readLocalJson(introPath); } // --------------------------------------------------------------------------- // Intro segments // --------------------------------------------------------------------------- /** * All intro segments, ordered by their `order` field. */ export async function getTlkIntroSegments(): Promise { const results = await prisma.proseChapter.findMany({ where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" }, orderBy: { order: "asc" }, }); if (results.length > 0) { return results.map((doc) => ({ slug: doc.slug, title: doc.title, type: doc.type, book: doc.book, order: doc.order, paragraphs: normalizeParagraphs(doc.paragraphs), })); } // Fallback to local JSON return getLocalIntroSegments(); } /** * A single intro segment by slug. * Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug). */ export async function getTlkIntroSegment( slug: string, ): Promise { // DB stores with "intro-" prefix; accept either form const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`; const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug; const doc = await prisma.proseChapter.findUnique({ where: { book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug }, }, }); if (doc) { return { slug: doc.slug, title: doc.title, type: doc.type, book: doc.book, order: doc.order, paragraphs: normalizeParagraphs(doc.paragraphs), }; } // Fallback to local JSON const local = getLocalIntroSegment(rawSlug); if (!local) return null; return { ...local, slug: dbSlug, paragraphs: local.paragraphs.map(sanitizeHtml), }; } // --------------------------------------------------------------------------- // Chapters // --------------------------------------------------------------------------- /** * A numbered chapter (1–18). */ export async function getTlkChapter( chapterNum: number, ): Promise { if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) { return null; } const doc = await prisma.proseChapter.findUnique({ where: { book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: String(chapterNum), }, }, }); if (doc) { return { book: doc.book, chapter: doc.chapter, title: doc.title, type: doc.type, slug: doc.slug, paragraphs: normalizeParagraphs(doc.paragraphs), }; } // Fallback to local JSON const local = getLocalChapter(chapterNum); if (!local) return null; return { ...local, paragraphs: local.paragraphs.map(sanitizeHtml), }; } // --------------------------------------------------------------------------- // Unified content accessor // --------------------------------------------------------------------------- /** * Get content by URL slug. * * "intro-front-pages" → intro segment * "intro-foreword" → intro segment * "intro-about-the-author" → intro segment * "intro-credits" → intro segment * "intro-back-flap" → intro segment * "1" … "18" → chapter */ export async function getTlkContent( slug: string, ): Promise { if (slug.startsWith("intro-")) { return getTlkIntroSegment(slug); } const num = Number(slug); if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) { return getTlkChapter(num); } return null; } // --------------------------------------------------------------------------- // Sidebar navigation // --------------------------------------------------------------------------- /** * Build the full sidebar navigation list. * * Order: front-pages → foreword → chapters 1–18 → about-the-author → credits → back-flap */ export async function getTlkSidebarItems(): Promise { const [meta, allDocs] = await Promise.all([ getTlkMeta(), prisma.proseChapter.findMany({ where: { book: BOOK, volume: NO_VOLUME }, orderBy: { order: "asc" }, select: { slug: true, title: true, type: true, chapter: true, order: true }, }), ]); // If we got DB results, build from those if (meta && allDocs.length > 0) { return buildSidebarFromDocs(meta, allDocs); } // Fallback: build from local JSON return buildSidebarFromLocal(meta); } function buildSidebarFromDocs( meta: TlkMeta, allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[], ): TlkSidebarItem[] { const items: TlkSidebarItem[] = []; // Intro segments that come BEFORE chapters (order < 50) for (const doc of allDocs) { if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) { items.push({ slug: doc.slug, label: doc.title, type: "intro-segment", chapter: null, }); } } // Chapters 1–18 (use meta.chapters for consistent title/ordering) for (const ch of meta.chapters) { items.push({ slug: String(ch.number), label: `Chapter ${ch.number}: ${ch.title}`, type: "chapter", chapter: ch.number, }); } // Intro segments that come AFTER chapters (order >= 50, e.g. about-the-author, credits, back-flap) for (const doc of allDocs) { if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) { items.push({ slug: doc.slug, label: doc.title, type: "intro-segment", chapter: null, }); } } return items; } function buildSidebarFromLocal(meta: TlkMeta | null): TlkSidebarItem[] { const items: TlkSidebarItem[] = []; // Local intro segments (front matter) const introSegs = getLocalIntroSegments(); const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50); const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50); for (const seg of frontMatter) { items.push({ slug: `intro-${seg.slug}`, label: seg.title, type: "intro-segment", chapter: null, }); } // Chapters from meta or generate default 1-18 if (meta && meta.chapters) { for (const ch of meta.chapters) { items.push({ slug: String(ch.number), label: `Chapter ${ch.number}: ${ch.title}`, type: "chapter", chapter: ch.number, }); } } else { // Fallback: scan local chapter JSONs const chaptersDir = path.join(TLK_JSON_DIR, "chapters"); if (fs.existsSync(chaptersDir)) { const files = fs .readdirSync(chaptersDir) .filter((f) => f.endsWith(".json")) .sort(); for (const file of files) { const ch = readLocalJson(path.join(chaptersDir, file)); if (ch) { const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10); items.push({ slug: String(num), label: `Chapter ${num}: ${ch.title}`, type: "chapter", chapter: num, }); } } } } // Back matter (about-the-author, credits, back-flap) for (const seg of backMatter) { items.push({ slug: `intro-${seg.slug}`, label: seg.title, type: "intro-segment", chapter: null, }); } return items; }