Files
spsays-training/lib/tlc.ts
T
2026-04-17 22:51:58 +05:30

293 lines
7.7 KiB
TypeScript
Raw 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 Teachings of Lord Caitanya (TLC).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* TLC is a prose book (like KB) but has no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* 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";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "tlc";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TlcMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: TlcPart[];
chapters: TlcChapterEntry[];
};
export type TlcPart = {
number: number;
title: string;
chapters: number[];
};
export type TlcChapterEntry = {
number: number;
title: string;
};
export type TlcIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TlcChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TlcSidebarItem = {
/** URL-safe slug: "intro-front-flap", "1", "32" */
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(/<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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, parts, chapter list, etc.)
*/
export async function getTlcMeta(): Promise<TlcMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as TlcMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTlcIntroSegments(): Promise<TlcIntroSegment[]> {
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-preface" (URL slug) and "preface" (raw slug).
*/
export async function getTlcIntroSegment(
slug: string,
): Promise<TlcIntroSegment | 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),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–32).
*/
export async function getTlcChapter(
chapterNum: number,
): Promise<TlcChapter | null> {
if (chapterNum < 1 || chapterNum > 32 || !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 null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-flap" → intro segment
* "intro-preface" → intro segment
* "intro-introduction" → intro segment
* "1" … "32" → chapter
*/
export async function getTlcContent(
slug: string,
): Promise<TlcChapter | TlcIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTlcIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 32) {
return getTlcChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: intro segments → chapters 1–32
* (TLC has no concluding-words section.)
*/
export async function getTlcSidebarItems(): Promise<TlcSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTlcMeta(),
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: TlcSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–32 (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,
});
}
return items;
}