Files
2026-05-03 21:02:41 +05:30

307 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Data access layer for Prayers of King Kulaśekhara (PK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* PK is a prose book with no volume structure and no intro segments —
* 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/pk/.
*
* 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 = "pk";
const PK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "pk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PkMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: PkChapterEntry[];
};
export type PkChapterEntry = {
number: number;
title: string;
};
export type PkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PkSidebarItem = {
/** URL-safe slug: "1", "2", … "6" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\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<T>(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 getLocalChapter(chapterNum: number): PkChapter | null {
const filePath = path.join(
PK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<PkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPkMeta(): Promise<PkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as PkMeta;
// Fallback to local JSON
const introPath = path.join(PK_JSON_DIR, "intro.json");
return readLocalJson<PkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–6).
*/
export async function getPkChapter(
chapterNum: number,
): Promise<PkChapter | null> {
if (chapterNum < 1 || chapterNum > 6 || !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.
*
* "1" … "6" → chapter
*/
export async function getPkContent(slug: string): Promise<PkChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
return getPkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–6 (no intro segments for PK)
*/
export async function getPkSidebarItems(): Promise<PkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPkMeta(),
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: PkMeta,
_allDocs: {
slug: string;
title: string;
type: string;
chapter: number | null;
order: number | null;
}[],
): PkSidebarItem[] {
const items: PkSidebarItem[] = [];
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: PkMeta | null): PkSidebarItem[] {
const items: PkSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(PK_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<PkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `${num}. ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
}