Files
2026-04-17 22:51:58 +05:30

323 lines
9.0 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 The Nectar of Instruction (NoI).
*
* Reads from MongoDB via Prisma.
* NoI has 11 texts (verses) plus intro segments (front-pages, preface, appendixes).
*
* Verses were seeded with:
* - book: "noi", division: null
* - chapter = text number (1-11)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "noi", volume: 0, type: "intro-segment"
* - slugs like "intro-front-pages", "intro-preface", "intro-appendixes"
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BOOK = "noi";
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Text title lookup
// ---------------------------------------------------------------------------
const TEXT_TITLES: Record<number, string> = {
1: "Text One",
2: "Text Two",
3: "Text Three",
4: "Text Four",
5: "Text Five",
6: "Text Six",
7: "Text Seven",
8: "Text Eight",
9: "Text Nine",
10: "Text Ten",
11: "Text Eleven",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NoiMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: NoiTextEntry[];
};
export type NoiTextEntry = {
number: number;
title: string;
slug: string;
};
export type NoiIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type NoiVerse = {
book: string;
book_title: string;
text: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type NoiSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Text number (1-11), or null for non-verse items */
text: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/iso.ts, lib/nod.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);
}
/** Normalize purport via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, texts list, etc.)
*/
export async function getNoiMeta(): Promise<NoiMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as NoiMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getNoiIntroSegments(): Promise<NoiIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getNoiIntroSegment(
slug: string,
): Promise<NoiIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Verses (texts 1-11, from verses collection)
// ---------------------------------------------------------------------------
/**
* A single verse/text by slug.
*
* - "1"-"11" → chapter=1–11, verse=1
*/
export async function getNoiVerse(slug: string): Promise<NoiVerse | null> {
const textNum = Number(slug);
if (!Number.isInteger(textNum) || textNum < 1 || textNum > 11) return null;
const doc = await prisma.verse.findFirst({
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
text: doc.chapter, // chapter field stores the text number
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
reference: doc.reference,
devanagari: doc.devanagari,
transliteration: doc.transliteration,
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
translation: sanitizeHtml(doc.translation),
purport: normalizePurport(doc.purport),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "intro-appendixes" → intro segment
* "1" … "11" → verse/text
*/
export async function getNoiContent(
slug: string,
): Promise<NoiVerse | NoiIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getNoiIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getNoiVerse(String(num));
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → texts 1-11 → appendixes
*/
export async function getNoiSidebarItems(): Promise<NoiSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getNoiMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: NoiSidebarItem[] = [];
// 1. Intro segments that come before the texts (front-pages, preface — by order)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && doc.slug !== "intro-appendixes") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
text: null,
});
}
}
// 2. Texts 1-11 (from meta.texts)
for (const t of meta.texts) {
items.push({
slug: String(t.number),
label: `${t.number}. ${t.title}`,
type: "verse",
text: t.number,
});
}
// 3. Appendixes intro segment (at the end)
const appendixes = allDocs.find((d) => d.slug === "intro-appendixes");
if (appendixes) {
items.push({
slug: appendixes.slug,
label: appendixes.title,
type: "intro-segment",
text: null,
});
}
return items;
}