/** * Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA). * * Reads from MongoDB via Prisma, same as the rest of the app. * SVA is a hierarchical song book with 4 parent parts, each containing * multiple songs (sub-chapters). Each song is stored as its own * prose_chapters document with slugs like "1-1", "1-2", "2-1", etc. * * When the DB isn't seeded yet, falls back to reading local JSON files * from lib/jsons/books/sva/. * * 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 // --------------------------------------------------------------------------- const BOOK = "sva"; /** Volume sentinel for books without volumes. */ const NO_VOLUME = 0; const SVA_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "sva"); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type SvaMeta = { book: string; title: string; author: string; year: number; description: string; totalChapters: number; chapters: SvaParentChapter[]; }; export type SvaParentChapter = { number: number; title: string; subChapters: SvaSubChapterEntry[]; }; export type SvaSubChapterEntry = { slug: string; // "1-1", "1-2", etc. title: string; }; export type SvaIntroSegment = { slug: string; title: string; type: string; book: string; order: number; paragraphs: string[]; }; export type SvaChapter = { book: string; chapter: string; // "1-1", "2-1", etc. parentChapter: number; subChapter: number; title: string; parentTitle: string; type: string; paragraphs: string[]; }; /** Union type used in sidebar / table-of-contents navigation. */ export type SvaSidebarItem = { /** URL-safe slug: "intro-front-pages", "1-1", "parent-1", etc. */ slug: string; /** Human-readable label for the sidebar */ label: string; /** "intro-segment" | "parent" | "sub-chapter" */ type: string; /** Parent chapter number, or null for intro segments */ parentChapter: number | null; }; // --------------------------------------------------------------------------- // 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; } /** 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 { try { if (!fs.existsSync(filePath)) return null; return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; } catch { return null; } } function getLocalIntroSegments(): SvaIntroSegment[] { const introDir = path.join(SVA_JSON_DIR, "intro"); if (!fs.existsSync(introDir)) return []; const results: SvaIntroSegment[] = []; const files = ["front-pages.json", "foreword.json", "introduction.json"]; for (const file of files) { const filePath = path.join(introDir, file); const seg = readLocalJson(filePath); if (seg) { results.push({ ...seg, slug: seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`, }); } } return results.sort((a, b) => a.order - b.order); } function getLocalSubChapter(slug: string): SvaChapter | null { const filePath = path.join(SVA_JSON_DIR, "chapters", `${slug}.json`); return readLocalJson(filePath); } // --------------------------------------------------------------------------- // Book meta // --------------------------------------------------------------------------- /** * Book-level metadata (title, author, chapter list with sub-chapters, etc.) */ export async function getSvaMeta(): Promise { try { const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); if (meta) return meta.data as unknown as SvaMeta; } catch { // fall through to local JSON } // Fallback to local JSON const introPath = path.join(SVA_JSON_DIR, "intro.json"); return readLocalJson(introPath); } // --------------------------------------------------------------------------- // Intro segments // --------------------------------------------------------------------------- /** * All intro segments, ordered by their `order` field. */ export async function getSvaIntroSegments(): Promise { try { 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), })); } } catch { // fall through to local JSON } // 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 getSvaIntroSegment( 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; try { 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), }; } } catch { // fall through to local JSON } // Fallback to local JSON const filePath = path.join(SVA_JSON_DIR, "intro", `${rawSlug}.json`); const local = readLocalJson(filePath); if (!local) return null; return { ...local, slug: dbSlug, paragraphs: local.paragraphs.map(sanitizeHtml), }; } // --------------------------------------------------------------------------- // Chapters (sub-chapters / songs like "1-1", "2-3", etc.) // --------------------------------------------------------------------------- /** * A sub-chapter (song) by its slug (e.g. "1-1", "2-3"). */ export async function getSvaChapter(slug: string): Promise { try { const doc = await prisma.proseChapter.findUnique({ where: { book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug, }, }, }); if (doc) { // Parse parentChapter and subChapter from slug "X-Y" const parts = slug.split("-"); const parentChapter = parseInt(parts[0], 10); const subChapter = parseInt(parts[1], 10); // Derive parentTitle from meta if available const meta = await getSvaMeta(); const parentEntry = meta?.chapters.find( (ch) => ch.number === parentChapter, ); const parentTitle = parentEntry?.title ?? ""; return { book: doc.book, chapter: slug, parentChapter, subChapter, title: doc.title, parentTitle, type: doc.type, paragraphs: normalizeParagraphs(doc.paragraphs), }; } } catch { // fall through to local JSON } // Fallback to local JSON const local = getLocalSubChapter(slug); 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-introduction" → intro segment * "1-1", "2-3", etc. → sub-chapter (song) */ export async function getSvaContent( slug: string, ): Promise { if (slug.startsWith("intro-")) { return getSvaIntroSegment(slug); } // Sub-chapter slug like "1-1", "2-3" return getSvaChapter(slug); } // --------------------------------------------------------------------------- // Sidebar navigation // --------------------------------------------------------------------------- /** * Build the full sidebar navigation list with hierarchical structure. * * Order: intro segments (front-pages, foreword, introduction) * → parent-1 (label) → 1-1, 1-2, ... * → parent-2 (label) → 2-1, 2-2, ... * → etc. * * "parent" type items are non-clickable headings. Only "intro-segment" and * "sub-chapter" items are navigable pages. */ export async function getSvaSidebarItems(): Promise { let allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number; }[] = []; const meta = await getSvaMeta(); try { const results = await prisma.proseChapter.findMany({ where: { book: BOOK, volume: NO_VOLUME }, orderBy: { order: "asc" }, select: { slug: true, title: true, type: true, chapter: true, order: true, }, }); allDocs = results; } catch { // Prisma error — build sidebar from meta alone } if (!meta) { // Try building from local JSON const localMeta = readLocalJson( path.join(SVA_JSON_DIR, "intro.json"), ); if (!localMeta) return []; return buildSidebarFromMeta(localMeta); } // If we have no DB docs, build from meta alone if (allDocs.length === 0) { return buildSidebarFromMeta(meta); } const items: SvaSidebarItem[] = []; // Intro segments that come BEFORE chapters (order < 99) for (const doc of allDocs) { if (doc.type === "intro-segment" && doc.order < 99) { items.push({ slug: doc.slug, label: doc.title, type: "intro-segment", parentChapter: null, }); } } // Chapters — grouped by parent chapter with sub-chapters for (const ch of meta.chapters) { // Parent heading (non-clickable label) items.push({ slug: `parent-${ch.number}`, label: `Part ${ch.number}: ${ch.title}`, type: "parent", parentChapter: ch.number, }); // Sub-chapters under this parent for (const sub of ch.subChapters) { items.push({ slug: sub.slug, label: sub.title, type: "sub-chapter", parentChapter: ch.number, }); } } // Intro segments that come AFTER chapters (order >= 99) for (const doc of allDocs) { if (doc.type === "intro-segment" && doc.order >= 99) { items.push({ slug: doc.slug, label: doc.title, type: "intro-segment", parentChapter: null, }); } } return items; } /** * Build sidebar items purely from metadata (when DB is unavailable). */ function buildSidebarFromMeta(meta: SvaMeta): SvaSidebarItem[] { const items: SvaSidebarItem[] = []; // Hardcoded intro segments items.push({ slug: "intro-front-pages", label: "Front Pages", type: "intro-segment", parentChapter: null, }); items.push({ slug: "intro-foreword", label: "Foreword", type: "intro-segment", parentChapter: null, }); items.push({ slug: "intro-introduction", label: "Introduction", type: "intro-segment", parentChapter: null, }); for (const ch of meta.chapters) { items.push({ slug: `parent-${ch.number}`, label: `Part ${ch.number}: ${ch.title}`, type: "parent", parentChapter: ch.number, }); for (const sub of ch.subChapters) { items.push({ slug: sub.slug, label: sub.title, type: "sub-chapter", parentChapter: ch.number, }); } } return items; } /** * Get the flat list of navigable sidebar items (excludes "parent" headings). * Used for prev/next navigation. */ export function getNavigableItems(items: SvaSidebarItem[]): SvaSidebarItem[] { return items.filter((item) => item.type !== "parent"); }