feat: implement bff layer

This commit is contained in:
gosangam
2026-05-10 00:35:14 +05:30
parent 5951eb5e18
commit 32da38b24f
91 changed files with 2434 additions and 11784 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getBookMeta } from "@/lib/api";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ book: string }> }
{ params }: { params: Promise<{ book: string }> },
) {
const { book } = await params;
const meta = await prisma.bookMeta.findUnique({ where: { book } });
const meta = await getBookMeta(book);
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> });
return NextResponse.json(meta);
}
+21 -37
View File
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import {
getSingleVerse,
getChapterVerseIndex,
getChapterVerses,
} from "@/lib/api";
export async function GET(
req: Request,
@@ -7,7 +11,7 @@ export async function GET(
) {
const { book } = await params;
const url = new URL(req.url);
const division = url.searchParams.get("division");
const division = url.searchParams.get("division") || null;
const chapterStr = url.searchParams.get("chapter");
const verseStr = url.searchParams.get("verse");
const indexOnly = url.searchParams.get("indexOnly") === "true";
@@ -22,20 +26,18 @@ export async function GET(
}
const chapter = parseInt(chapterStr);
const where: Record<string, unknown> = { book, chapter };
if (book === "bg") {
where.division = null;
} else if (division) {
where.division = division;
}
const effectiveDivision = book === "bg" ? null : division;
// --- Single verse mode: ?verse=N ---
if (verseStr) {
const verseNum = parseInt(verseStr);
const verse = await prisma.verse.findFirst({
where: { ...where, verse: verseNum },
});
const verse = await getSingleVerse(
book,
effectiveDivision,
chapter,
verseNum,
);
if (!verse) {
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
@@ -46,15 +48,7 @@ export async function GET(
// --- Index-only mode: return lightweight verse list for the sidebar ---
if (indexOnly) {
const verses = await prisma.verse.findMany({
where,
orderBy: { verse: "asc" },
select: {
verse: true,
verseEnd: true,
reference: true,
},
});
const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
return NextResponse.json({ verses, total: verses.length });
}
@@ -62,24 +56,14 @@ export async function GET(
// --- Paginated mode: return full verse data in pages ---
const page = Math.max(1, parseInt(pageStr || "1"));
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
const skip = (page - 1) * limit;
const [verses, total] = await Promise.all([
prisma.verse.findMany({
where,
orderBy: { verse: "asc" },
skip,
take: limit,
}),
prisma.verse.count({ where }),
]);
return NextResponse.json({
verses,
total,
const result = await getChapterVerses(
book,
effectiveDivision,
chapter,
page,
limit,
totalPages: Math.ceil(total / limit),
hasMore: skip + verses.length < total,
});
);
return NextResponse.json(result);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getAllBooks } from "@/lib/api";
export async function GET() {
const metas = await prisma.bookMeta.findMany();
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> })));
const books = await getAllBooks();
return NextResponse.json(books);
}
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getAllGlossaryEntries } from "@/lib/api";
export async function GET() {
const entries = await prisma.glossaryEntry.findMany({
orderBy: { term: "asc" },
});
const entries = await getAllGlossaryEntries();
return NextResponse.json(entries);
}
-1
View File
@@ -60,7 +60,6 @@ export default async function BbdPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -162,14 +162,7 @@ function SidebarLink({
item: BkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-2
View File
@@ -118,7 +118,6 @@ export default async function BsPage() {
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -133,7 +132,6 @@ export default async function BsPage() {
{foreword.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{foreword.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -162,14 +162,7 @@ function SidebarLink({
item: CbhcSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-1
View File
@@ -63,7 +63,6 @@ export default async function DsPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -63,7 +63,6 @@ export default async function EjPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function EkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -217,14 +217,7 @@ function SidebarLink({
item: GgSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-2
View File
@@ -64,7 +64,6 @@ export default async function GgPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function GgPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function HkhPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+7 -4
View File
@@ -50,8 +50,11 @@ export async function generateMetadata({
const isVerse_ = content && "mantra" in content && "devanagari" in content;
const paragraphs = content
? isVerse_
? [(content as IsoVerse).translation, ...(content as IsoVerse).purport]
: (content as IsoIntroSegment).paragraphs
? [
(content as unknown as IsoVerse).translation,
...(content as unknown as IsoVerse).purport,
]
: (content as unknown as IsoIntroSegment).paragraphs
: undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
@@ -64,7 +67,7 @@ export async function generateMetadata({
}
const title = isVerse_
? `${(content as IsoVerse).reference}: ${(content as IsoVerse).title}`
? `${(content as unknown as IsoVerse).reference}`
: content && "title" in content
? (content as IsoIntroSegment).title
: `Mantra ${slug}`;
@@ -110,7 +113,7 @@ export default async function IsoMantraPage({
{verse && (
<>
<span className="text-primary">{verse.reference}: </span>
{verse.title}
{verse.bookTitle}
</>
)}
{intro && intro.title}
-3
View File
@@ -68,7 +68,6 @@ export default async function IsoPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{frontPages.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -85,7 +84,6 @@ export default async function IsoPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -164,7 +162,6 @@ export default async function IsoPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{aboutAuthor.paragraphs.length} paragraphs
</span>
</Link>
</li>
-2
View File
@@ -72,7 +72,6 @@ export default async function KbVolumePage({
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -106,7 +105,6 @@ export default async function KbVolumePage({
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{ch.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -221,14 +221,7 @@ function SidebarLink({
item: LclSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-2
View File
@@ -64,7 +64,6 @@ export default async function LclPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function LclPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function LobPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function MgPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -116,7 +116,6 @@ export default async function MmPage() {
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function MogPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -116,7 +116,6 @@ export default async function NbsPage() {
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -203,14 +203,7 @@ function SidebarLink({
item: NodSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-1
View File
@@ -70,7 +70,6 @@ export default async function NodPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-3
View File
@@ -65,7 +65,6 @@ export default async function NoiPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{frontPages.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -82,7 +81,6 @@ export default async function NoiPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{preface.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -141,7 +139,6 @@ export default async function NoiPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{appendixes.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function OchkmPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function OwkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function PoyPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -239,14 +239,7 @@ function SidebarLink({
item: PqpaSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-2
View File
@@ -64,7 +64,6 @@ export default async function PqpaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -151,7 +150,6 @@ export default async function PqpaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-1
View File
@@ -60,7 +60,6 @@ export default async function RvPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-2
View File
@@ -64,7 +64,6 @@ export default async function SsrPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -123,7 +122,6 @@ export default async function SsrPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
-2
View File
@@ -64,7 +64,6 @@ export default async function SvaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -123,7 +122,6 @@ export default async function SvaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+45 -10
View File
@@ -141,6 +141,31 @@ function TlcSidebar({
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
// Group chapters by part (if parts exist)
const hasParts = chapterItems.some((item) => item.partNumber != null);
const partGroups: {
partNumber: number;
partTitle: string;
chapters: TlcSidebarItem[];
}[] = [];
if (hasParts) {
const partMap = new Map<
number,
{ partTitle: string; chapters: TlcSidebarItem[] }
>();
for (const item of chapterItems) {
const pn = item.partNumber ?? 0;
const pt = item.partTitle ?? `Part ${pn}`;
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
partMap.get(pn)!.chapters.push(item);
}
for (const [partNumber, { partTitle, chapters }] of partMap) {
partGroups.push({ partNumber, partTitle, chapters });
}
partGroups.sort((a, b) => a.partNumber - b.partNumber);
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
@@ -161,8 +186,25 @@ function TlcSidebar({
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
{/* Chapters — grouped by part if available */}
{hasParts
? partGroups.map((group) => (
<div key={group.partNumber} className="mb-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
{group.partTitle}
</h2>
<nav className="flex flex-col gap-0.5">
{group.chapters.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</div>
))
: chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
@@ -189,14 +231,7 @@ function SidebarLink({
item: TlcSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-1
View File
@@ -68,7 +68,6 @@ export default async function TlcPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -226,14 +226,7 @@ function SidebarLink({
item: TlkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-2
View File
@@ -64,7 +64,6 @@ export default async function TlkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function TlkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+1 -8
View File
@@ -226,14 +226,7 @@ function SidebarLink({
item: TqkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
-2
View File
@@ -64,7 +64,6 @@ export default async function TqkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function TqkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+11 -27
View File
@@ -10,8 +10,7 @@ import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers)
@@ -31,25 +30,6 @@ function sanitizeHtml(html: string): string {
return clean;
}
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);
}
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
@@ -69,7 +49,14 @@ type SidebarItem = {
};
async function getEssay(slug: string): Promise<EssayData | null> {
const doc = await prisma.essay.findUnique({ where: { slug } });
const doc = await bff<{
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
paragraphs: string[];
}>(`/essays/${slug}`);
if (!doc) return null;
return {
slug: doc.slug,
@@ -77,15 +64,12 @@ async function getEssay(slug: string): Promise<EssayData | null> {
author: doc.author,
year: doc.year,
group: doc.group,
paragraphs: normalizeParagraphs(doc.paragraphs),
paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
};
}
async function getEssaySidebar(): Promise<SidebarItem[]> {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true },
});
return bffList<SidebarItem>("/essays");
}
// ---------------------------------------------------------------------------
+7 -5
View File
@@ -4,7 +4,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og";
import { prisma } from "@/lib/db";
import { bffList } from "@/lib/bff";
import { EssaysGrid } from "./essays-grid";
export function generateMetadata(): Metadata {
@@ -17,10 +17,12 @@ export function generateMetadata(): Metadata {
}
async function getAllEssays() {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true, year: true, group: true },
});
return bffList<{
slug: string;
title: string;
year: number | null;
group: string | null;
}>("/essays");
}
export default async function EssaysPage() {
@@ -67,7 +67,11 @@ export function PlayAudioButton({
aria-label={isThisPlaying ? "Pause" : "Play audio"}
>
{isThisPlaying ? (
<Pause className="size-3.5" />
<AudioWaveAnimation
paused={false}
colorClassName="bg-primary-foreground"
size="sm"
/>
) : (
<Play className="size-3.5 ml-0.5" />
)}
+13 -2
View File
@@ -8,7 +8,8 @@ import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react";
import { Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({
params,
@@ -60,7 +61,7 @@ export default async function TranscriptionsByPlacePage({
<Link
key={t.slug}
href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@@ -87,6 +88,16 @@ export default async function TranscriptionsByPlacePage({
)}
</div>
</div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link>
))}
</CardContent>
@@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
import {
Drawer,
DrawerClose,
@@ -34,6 +35,7 @@ export type TranscriptionItem = {
reference: string | null;
durationMinutes: number | null;
participants: string[];
audioUrl: string | null;
};
type YearGroup = {
@@ -364,7 +366,7 @@ function MonthSection({
<li key={t.slug}>
<Link
href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 pl-12 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@@ -396,6 +398,16 @@ function MonthSection({
)}
</div>
</div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link>
</li>
))}
+1 -1
View File
@@ -5,7 +5,7 @@ export function SiteFooter() {
<footer className="border-t border-border bg-card mt-auto">
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<p>
&copy; {new Date().getFullYear()}{" "}
Team {" "}
<span className="font-medium text-foreground">Prabhupāda Says</span>
</p>
+211 -128
View File
@@ -1,13 +1,11 @@
/**
* Data access layer for server components.
*
* These functions query MongoDB via Prisma and are called directly by
* Next.js server components. The /api/* routes exist for external consumers
* and for client-side verse-navigation fetching.
* These functions call the Go BFF API server instead of querying the DB
* directly. The BFF handles sanitization, pagination, and caching.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList, bffOrThrow } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
@@ -33,6 +31,12 @@ export type VerseRecord = {
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
/** Alias for bookTitle — used by ISO/BS/NOI pages */
title?: string;
/** Alias for verse number — used by NOI pages as "text number" */
text?: number;
/** Alias for verse/chapter number — used by ISO pages as "mantra number" */
mantra?: number;
};
export type VerseIndexEntry = {
@@ -73,28 +77,133 @@ export type ProseChapterRecord = {
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
function sanitizeParagraphs(paragraphs: string[] | undefined): string[] {
if (!paragraphs || !Array.isArray(paragraphs)) return [];
return paragraphs.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a BFF path for verse-related endpoints.
* Books like BG and SB1962 have no division (null), so we skip it.
* Books like SB and CC have a division (canto / līlā).
*/
function versePath(
book: string,
division: string | null,
chapter: number,
): string {
if (division) return `/books/${book}/${division}/${chapter}`;
return `/books/${book}/${chapter}`;
}
/**
* Map a BFF verse response (snake_case) to the frontend VerseRecord (camelCase).
*/
function mapVerse(v: Record<string, unknown>): VerseRecord {
const bookTitle = String(v.book_title ?? v.bookTitle ?? "");
const chapter = Number(v.chapter ?? 0);
const verse = Number(v.verse ?? 0);
return {
id: String(v.id ?? ""),
book: String(v.book ?? ""),
division: v.division ? String(v.division) : null,
chapter,
verse,
verseEnd:
v.verse_end != null
? Number(v.verse_end)
: v.verseEnd != null
? Number(v.verseEnd)
: null,
reference: String(v.reference ?? ""),
bookTitle,
devanagari: String(v.devanagari ?? ""),
bengali: v.bengali ? String(v.bengali) : null,
transliteration: String(v.transliteration ?? ""),
synonyms: (v.synonyms as { word: string; meanings: string[] }[]) ?? [],
translation: String(v.translation ?? ""),
purport: (v.purport as string[]) ?? [],
// Aliases for page backwards compatibility
title: bookTitle,
text: verse,
mantra: chapter,
};
}
/**
* Map a BFF verse-index entry (snake_case) to VerseIndexEntry (camelCase).
*/
function mapVerseIndex(v: Record<string, unknown>): VerseIndexEntry {
return {
verse: Number(v.verse ?? 0),
verseEnd: v.verse_end ? Number(v.verse_end) : null,
reference: String(v.reference ?? ""),
};
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Get all book metas — each has { book, ...data } where data is the intro.json blob.
*
* The BFF returns { id, book, data: { title, author, chapters, ... } }.
* We flatten it to { book, title, author, chapters, ... } for the frontend.
*/
export async function getAllBooks(): Promise<BookMeta[]> {
const metas = await prisma.bookMeta.findMany();
return metas.map((m) => ({
book: m.book,
...(m.data as Record<string, unknown>),
}));
const raw = await bffList<{ book: string; data: Record<string, unknown> }>(
"/books",
{ tags: ["books"] },
);
return raw.map(flattenBookMeta);
}
/**
* Get a single book's meta by slug ("bg", "sb", "cc").
*/
export async function getBookMeta(book: string): Promise<BookMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book } });
if (!meta) return null;
return { book: meta.book, ...(meta.data as Record<string, unknown>) };
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
`/books/${book}`,
{ tags: [`book-${book}`] },
);
if (!raw) return null;
return flattenBookMeta(raw);
}
/**
* Flatten a BFF book meta response.
* BFF returns: { id, book, data: { title, author, chapters, ... } }
* Frontend expects: { book, title, author, chapters, ... }
*/
function flattenBookMeta(raw: {
book: string;
data: Record<string, unknown>;
}): BookMeta {
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
return { book: raw.book, ...data } as BookMeta;
}
// ---------------------------------------------------------------------------
@@ -115,17 +224,14 @@ export async function getChapterVerseIndex(
division: string | null,
chapter: number,
): Promise<VerseIndexEntry[]> {
const entries = await prisma.verse.findMany({
where: { book, division, chapter },
orderBy: { verse: "asc" },
select: {
verse: true,
verseEnd: true,
reference: true,
},
const path = versePath(book, division, chapter);
const res = await bff<{
verses?: Record<string, unknown>[];
}>(path, {
tags: [`verses-${book}`],
});
return entries;
if (!res || !res.verses) return [];
return res.verses.map(mapVerseIndex);
}
// ---------------------------------------------------------------------------
@@ -147,27 +253,41 @@ export async function getChapterVerses(
page: number = 1,
limit: number = DEFAULT_PAGE_SIZE,
): Promise<PaginatedVerses> {
const skip = (page - 1) * limit;
const path = versePath(book, division, chapter);
const where = { book, division, chapter };
const [verses, total] = await Promise.all([
prisma.verse.findMany({
where,
orderBy: { verse: "asc" },
skip,
take: limit,
}),
prisma.verse.count({ where }),
]);
const raw = await bff<{
verses: Record<string, unknown>[];
total: number;
page: number;
limit: number;
totalPages: number;
hasMore: boolean;
}>(`${path}/verses`, {
params: {
page: String(page),
limit: String(limit),
},
tags: [`verses-${book}`],
});
if (!raw) {
return {
verses: verses as unknown as VerseRecord[],
total,
verses: [],
total: 0,
page,
limit,
totalPages: Math.ceil(total / limit),
hasMore: skip + verses.length < total,
totalPages: 0,
hasMore: false,
};
}
return {
verses: (raw.verses ?? []).map(mapVerse),
total: raw.total,
page: raw.page,
limit: raw.limit,
totalPages: raw.totalPages,
hasMore: raw.hasMore,
};
}
@@ -184,11 +304,11 @@ export async function getSingleVerse(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const result = await prisma.verse.findFirst({
where: { book, division, chapter, verse },
const path = versePath(book, division, chapter);
const raw = await bff<Record<string, unknown>>(`${path}/${verse}`, {
tags: [`verses-${book}`],
});
return result as unknown as VerseRecord | null;
return raw ? mapVerse(raw) : null;
}
// ---------------------------------------------------------------------------
@@ -199,109 +319,76 @@ export async function getSingleVerse(
* Get all prose chapters/segments for a given book and volume,
* optionally filtered by type ("chapter" | "intro-segment").
* Ordered by the `order` field.
*
* For KB (which has real volumes), uses /api/v1/books/kb/{volume}.
* For other books (volume=0 sentinel), uses /api/v1/books/{code}/chapters.
*/
export async function getProseChapters(
book: string,
volume: number,
type?: string,
): Promise<ProseChapterRecord[]> {
const where: Record<string, unknown> = { book, volume };
if (type) where.type = type;
// KB has real volumes (1, 2, 3, …); other prose books use volume=0 as sentinel
const path =
book === "kb" && volume > 0
? `/books/kb/${volume}`
: `/books/${book}/chapters`;
const results = await prisma.proseChapter.findMany({
where,
orderBy: { order: "asc" },
const params: Record<string, string> = {};
if (type) params.type = type;
const chapters = await bffList<ProseChapterRecord>(path, {
params: Object.keys(params).length > 0 ? params : undefined,
tags: [`book-${book}`],
});
return results.map(normalizeProse);
return chapters.map((ch) => ({
...ch,
paragraphs: sanitizeParagraphs(ch.paragraphs),
id: ch.id ?? "",
book: ch.book ?? book,
volume: ch.volume ?? volume,
chapter: ch.chapter ?? null,
title: ch.title ?? "",
type: ch.type ?? "chapter",
slug: ch.slug ?? "",
order: ch.order ?? 0,
}));
}
/**
* Get a single prose chapter by book + volume + slug.
*
* For KB, uses /api/v1/books/kb/{volume}/{slug}.
* For other books, uses /api/v1/books/{code}/chapters/{slug}.
*/
export async function getProseChapter(
book: string,
volume: number,
slug: string,
): Promise<ProseChapterRecord | null> {
const result = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book, volume, slug },
},
const path =
book === "kb" && volume > 0
? `/books/kb/${volume}/${slug}`
: `/books/${book}/chapters/${slug}`;
const chapter = await bff<ProseChapterRecord>(path, {
tags: [`book-${book}`],
});
return result ? normalizeProse(result) : null;
}
/**
* Normalize a prose chapter from the DB.
* Some chapters were stored with paragraphs as a JSON string (fallback
* for BSON wire-protocol corruption). This normalizes them back to arrays.
*/
function normalizeProse(doc: {
id: string;
book: string;
volume: number;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[] | JsonValue;
}): ProseChapterRecord {
let paragraphs: string[];
if (typeof doc.paragraphs === "string") {
// Was stored as stringified JSON — parse it back
try {
paragraphs = JSON.parse(doc.paragraphs);
} catch {
paragraphs = [doc.paragraphs];
}
} else if (Array.isArray(doc.paragraphs)) {
paragraphs = doc.paragraphs as string[];
} else {
paragraphs = [];
}
// Sanitize HTML — strip dangerous tags and event handlers
paragraphs = paragraphs.map(sanitizeHtml);
if (!chapter) return null;
return {
id: doc.id,
book: doc.book,
volume: doc.volume,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
order: doc.order,
paragraphs,
...chapter,
paragraphs: sanitizeParagraphs(chapter.paragraphs),
id: chapter.id ?? "",
book: chapter.book ?? book,
volume: chapter.volume ?? volume,
chapter: chapter.chapter ?? null,
title: chapter.title ?? "",
type: chapter.type ?? "chapter",
slug: chapter.slug ?? slug,
order: chapter.order ?? 0,
};
}
/**
* Sanitize an HTML string by stripping dangerous tags and attributes.
* Keeps safe formatting tags: <i>, <b>, <em>, <strong>, <br>, <div>, <span>.
*/
function sanitizeHtml(html: string): string {
// Remove <script>...</script>, <style>...</style>, <iframe>...</iframe>
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, "");
// Remove lone <script>, <style>, <iframe> opening/closing tags
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
// Remove event handler attributes (onclick, onload, onerror, etc.)
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
// Remove javascript: URLs
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
// ---------------------------------------------------------------------------
// Glossary
// ---------------------------------------------------------------------------
@@ -310,9 +397,5 @@ function sanitizeHtml(html: string): string {
* Get all glossary entries, ordered alphabetically by term.
*/
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
const entries = await prisma.glossaryEntry.findMany({
orderBy: { term: "asc" },
});
return entries as unknown as GlossaryRecord[];
return bffList<GlossaryRecord>("/glossary", { tags: ["glossary"] });
}
+26 -249
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for Beyond Birth and Death (BBD).
*
* Reads from MongoDB via Prisma.
* BBD is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "bbd";
const BOOK = "bbd";
// Re-export types with book-specific names for backwards compatibility
export type BbdMeta = BookMeta;
export type BbdChapterEntry = { number: number; title: string };
export type BbdIntroSegment = IntroSegment;
export type BbdChapter = Chapter;
export type BbdSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BbdMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: BbdChapterEntry[];
};
export type BbdChapterEntry = {
number: number;
title: string;
};
export type BbdIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type BbdChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BbdSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getBbdMeta(): Promise<BbdMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as BbdMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getBbdIntroSegment(
slug: string,
): Promise<BbdIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getBbdChapter(
chapterNum: number,
): Promise<BbdChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getBbdContent(
slug: string,
): Promise<BbdChapter | BbdIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getBbdIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getBbdChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getBbdMeta(),
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: BbdSidebarItem[] = [];
// 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–5 (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;
return getSidebarItems(CODE);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* BFF API client for server components.
*
* Provides typed fetch helpers that call the Go BFF API server.
* Used by all data layer files (lib/*.ts) instead of direct Prisma queries.
*
* The BFF URL is configured via the BFF_URL environment variable.
*/
const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
/** Build a full BFF API URL. */
function apiUrl(path: string, params?: Record<string, string>): string {
const url = new URL(`/api/v1${path}`, BFF_BASE);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
/** Custom error for BFF API failures. */
export class BffError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "BffError";
}
}
/**
* Fetch JSON from the BFF API.
*
* - Throws BffError on non-2xx responses
* - Returns null for 404 responses (instead of throwing)
* - Uses Next.js fetch with revalidation options
*
* @param path - API path (e.g., "/books/bg")
* @param options - Optional fetch options
* @param params - Optional query parameters
*/
export async function bff<T>(
path: string,
options?: {
params?: Record<string, string>;
/** Next.js revalidation in seconds. Default: 60. Set to 0 for no cache. */
revalidate?: number;
/** Next.js cache tags for on-demand revalidation */
tags?: string[];
},
): Promise<T | null> {
const url = apiUrl(path, options?.params);
const res = await fetch(url, {
next: {
revalidate: options?.revalidate ?? 60,
tags: options?.tags,
},
headers: {
Accept: "application/json",
},
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new BffError(
res.status,
`BFF ${res.status} ${path}: ${body.slice(0, 200)}`,
);
}
const json = await res.json();
// Unwrap BFF envelope: { success: true, data: T }
if (json && typeof json === "object" && "data" in json) {
return json.data as T;
}
// Fallback: return raw response if no envelope
return json as T;
}
/**
* Fetch JSON from the BFF API, throwing on 404.
* Use this when a missing resource should be an error, not null.
*/
export async function bffOrThrow<T>(
path: string,
options?: {
params?: Record<string, string>;
revalidate?: number;
tags?: string[];
},
): Promise<T> {
const result = await bff<T>(path, options);
if (result === null) {
throw new BffError(404, `Not found: ${path}`);
}
return result;
}
/**
* Fetch a list from the BFF API.
* Returns empty array on 404 (instead of null).
*/
export async function bffList<T>(
path: string,
options?: {
params?: Record<string, string>;
revalidate?: number;
tags?: string[];
},
): Promise<T[]> {
const result = await bff<T[]>(path, options);
return result ?? [];
}
+22 -292
View File
@@ -1,299 +1,29 @@
/**
* Data access layer for Bhagavāner Kathā (BK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* BK is a simple prose book with no volume structure and no intro segments —
* just 2 chapters directly. 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/bk/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import {
getBookMeta,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
const CODE = "bk";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export type BkMeta = BookMeta;
export type BkChapterEntry = { number: number; title: string };
export type BkChapter = Chapter;
export type BkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "bk";
const BK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BkMeta = {
book: string;
title: string;
author: string;
year?: number | null;
description: string;
totalChapters: number;
chapters: BkChapterEntry[];
};
export type BkChapterEntry = {
number: number;
title: string;
};
export type BkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BkSidebarItem = {
/** URL-safe slug: "1", "2" */
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;
export async function getBkMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getBkChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getBkContent(slug: string) {
return getContent(CODE, slug);
}
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): BkChapter | null {
const filePath = path.join(
BK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<BkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getBkMeta(): Promise<BkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as BkMeta;
// Fallback to local JSON
const introPath = path.join(BK_JSON_DIR, "intro.json");
return readLocalJson<BkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–2).
*/
export async function getBkChapter(
chapterNum: number,
): Promise<BkChapter | null> {
if (chapterNum < 1 || chapterNum > 2 || !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" … "2" → chapter
*/
export async function getBkContent(
slug: string,
): Promise<BkChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
return getBkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–2 (no intro segments)
*/
export async function getBkSidebarItems(): Promise<BkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getBkMeta(),
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);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(meta: BkMeta): BkSidebarItem[] {
const items: BkSidebarItem[] = [];
// Chapters 1–2 (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;
}
function buildSidebarFromLocal(meta: BkMeta | null): BkSidebarItem[] {
const items: BkSidebarItem[] = [];
// 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: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(BK_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<BkChapter>(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,
});
}
}
}
}
return items;
export async function getBkSidebarItems() {
return getSidebarItems(CODE);
}
+368
View File
@@ -0,0 +1,368 @@
/**
* Generic book data layer — replaces individual book files (bbd.ts, ej.ts, etc.)
*
* Calls the BFF API for book metadata, chapters, and intro segments.
* All prose books share the same BFF endpoints:
* GET /api/v1/books/{code} → book meta
* GET /api/v1/books/{code}/chapters → all chapters (with optional ?type= filter)
* GET /api/v1/books/{code}/chapters/{slug} → single chapter
*/
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types — generic, work for any prose book
// ---------------------------------------------------------------------------
export type BookMeta = {
book: string;
title: string;
author: string;
year?: number;
subtitle?: string;
description?: string;
totalChapters?: number;
chapters: ChapterEntry[];
/** Volume info for multi-volume books like KB */
volumes?: { number: number; title: string; chapters: ChapterEntry[] }[];
/** Parent chapters for hierarchical books (SSR, DS, SVA, NOD) — always [] for non-hierarchical books */
parts: {
number: number;
title: string;
subChapters: { slug: string; title: string }[];
/** NOD-style: array of chapter numbers belonging to this part */
chapters: number[];
}[];
/** For ISO — mantra entries (always [] for non-ISO books) */
mantras: { number: number; slug: string; title: string }[];
/** For BS/NOI — text entries (always [] for non-BS/NOI books) */
texts: { number: number; slug: string; title: string }[];
/** Total mantras (ISO) */
totalMantras?: number;
/** Total texts (BS, NOI) */
totalTexts?: number;
};
export type ChapterEntry = {
number: number;
title: string;
slug?: string;
subChapters: { slug: string; title: string }[];
};
export type IntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type Chapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[];
/** For hierarchical books (SSR, DS, SVA) — the parent chapter number */
parentChapter?: number;
/** For hierarchical sub-chapters — the sub-chapter number */
subChapter?: number;
};
export type SidebarItem = {
slug: string;
label: string;
type: string;
chapter: number | null;
/** For hierarchical books (SSR, DS, SVA) — the parent chapter number */
parentChapter?: number;
/** Sub-chapters within a parent chapter */
subChapters?: { slug: string; title: string }[];
/** For verse books (ISO) — mantra number */
mantra?: number;
/** For verse books (BS, NOI) — text number */
text?: number;
/** Part number this item belongs to (for books with parts like TLC, NOD) */
partNumber?: number;
/** Part title (e.g., "Part I", "Part One") */
partTitle?: string;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffChapter = {
id: string;
book: string;
volume: number;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[];
mantra?: number;
text?: number;
};
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
function mapChapter(c: BffChapter): Chapter {
return {
book: c.book,
chapter: c.chapter,
title: c.title,
type: c.type,
slug: c.slug,
order: c.order,
paragraphs: (c.paragraphs ?? []).map(sanitizeHtml),
};
}
function mapIntro(c: BffChapter): IntroSegment {
return {
slug: c.slug,
title: c.title,
type: c.type,
book: c.book,
order: c.order,
paragraphs: (c.paragraphs ?? []).map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Generic book functions
// ---------------------------------------------------------------------------
/**
* Get book metadata.
*/
export async function getBookMeta(code: string): Promise<BookMeta | null> {
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
`/books/${code}`,
{ tags: [`book-${code}`] },
);
if (!raw) return null;
// Flatten: BFF returns { id, book, data: { title, chapters, parts, ... } }
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
const meta = { book: raw.book, ...data } as BookMeta;
// Ensure arrays are always present
if (!meta.chapters) meta.chapters = [];
for (const ch of meta.chapters) {
if (!ch.subChapters) ch.subChapters = [];
}
if (!meta.mantras) meta.mantras = [];
if (!meta.texts) meta.texts = [];
if (!meta.parts) meta.parts = [];
for (const part of meta.parts) {
if (!part.subChapters) part.subChapters = [];
if (!part.chapters) part.chapters = [];
}
return meta;
}
/**
* Get all intro segments for a book.
*/
export async function getIntroSegments(code: string): Promise<IntroSegment[]> {
const chapters = await bffList<BffChapter>(`/books/${code}/chapters`, {
tags: [`book-${code}`],
});
return chapters.filter((c) => c.type === "intro-segment").map(mapIntro);
}
/**
* Get a single intro segment by slug.
*/
export async function getIntroSegment(
code: string,
slug: string,
): Promise<IntroSegment | null> {
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const chapter = await bff<BffChapter>(`/books/${code}/chapters/${dbSlug}`, {
tags: [`book-${code}`],
});
return chapter ? mapIntro(chapter) : null;
}
/**
* Get a single chapter by slug (numeric or named).
*/
export async function getChapter(
code: string,
slug: string,
): Promise<Chapter | null> {
const chapter = await bff<BffChapter>(`/books/${code}/chapters/${slug}`, {
tags: [`book-${code}`],
});
return chapter ? mapChapter(chapter) : null;
}
/**
* Get content by URL slug — routes to intro segment or chapter.
*/
export async function getContent(
code: string,
slug: string,
): Promise<Chapter | IntroSegment | null> {
if (slug.startsWith("intro-")) {
return getIntroSegment(code, slug);
}
// Try as chapter
return getChapter(code, slug);
}
/**
* Build sidebar navigation items for a book.
* Returns intro segments first, then chapters.
*/
export async function getSidebarItems(code: string): Promise<SidebarItem[]> {
const [meta, allChapters] = await Promise.all([
getBookMeta(code),
bffList<BffChapter>(`/books/${code}/chapters`, { tags: [`book-${code}`] }),
]);
if (!meta) return [];
const items: SidebarItem[] = [];
// Intro segments
for (const ch of allChapters) {
if (ch.type === "intro-segment") {
items.push({
slug: ch.slug,
label: ch.title,
type: "intro-segment",
chapter: null,
});
}
}
// Verse entries (mantras for ISO, texts for BS/NOI)
for (const ch of allChapters) {
if (ch.type === "verse") {
if (ch.mantra != null) {
items.push({
slug: ch.slug,
label: ch.mantra === 0 ? "Invocation" : `Mantra ${ch.mantra}`,
type: "verse",
chapter: null,
mantra: ch.mantra,
});
} else if (ch.text != null) {
items.push({
slug: ch.slug,
label: `Text ${ch.text}`,
type: "verse",
chapter: null,
text: ch.text,
});
}
}
}
// Chapters — if parts exist, group chapters under part headers
if (
meta.parts &&
meta.parts.length > 0 &&
meta.chapters &&
meta.chapters.length > 0
) {
// Build a lookup: chapter number → which part it belongs to
const chapterToPart = new Map<
number,
{ partNumber: number; partTitle: string }
>();
for (const part of meta.parts) {
for (const chNum of part.chapters) {
chapterToPart.set(chNum, {
partNumber: part.number,
partTitle: part.title,
});
}
}
for (const ch of meta.chapters) {
const partInfo = chapterToPart.get(ch.number);
items.push({
slug: ch.slug ?? String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
partNumber: partInfo?.partNumber,
partTitle: partInfo?.partTitle,
});
}
} else if (meta.chapters && meta.chapters.length > 0) {
// No parts — flat chapter list
for (const ch of meta.chapters) {
items.push({
slug: ch.slug ?? String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: use chapters from the DB ordered by order field
for (const ch of allChapters) {
if (ch.type === "chapter") {
items.push({
slug: ch.slug,
label: ch.title,
type: "chapter",
chapter: ch.chapter,
});
}
}
}
// Conclusion segments (if any)
for (const ch of allChapters) {
if (ch.type === "conclusion") {
items.push({
slug: ch.slug,
label: ch.title,
type: "conclusion",
chapter: null,
});
}
}
return items;
}
/**
* For hierarchical books (SSR, DS) — get the navigable items
* (intro segments + parent chapters that have subChapters).
* This matches the getNavigableItems() functions in ssr.ts and ds.ts.
*/
export function getNavigableItems(items: SidebarItem[]): SidebarItem[] {
return items;
}
+46 -333
View File
@@ -1,357 +1,70 @@
/**
* Data access layer for Śrī Brahma-saṁhitā (BS).
*
* Reads from MongoDB via Prisma.
* BS has BOTH verses (in the `verses` collection)
* AND prose intro segments (in the `prose_chapters` collection).
* Delegates to the generic book data layer (BFF API) for prose content,
* and to lib/api.ts for verse lookups.
*
* Verses were seeded with:
* - book: "bs", division: null
* - chapter = text number (1-62)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "bs", volume: 0, type: "intro-segment"
* - slugs like "intro-introduction", "intro-foreword"
* BS has 62 texts (verses) plus intro segments.
* Verses are stored with: book "bs", division null, chapter 5, verse = text number.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { getSingleVerse, type VerseRecord } from "@/lib/api";
const CODE = "bs";
// ---------------------------------------------------------------------------
// Constants
// Re-export types with book-specific names for backwards compatibility
// ---------------------------------------------------------------------------
const BOOK = "bs";
const NO_VOLUME = 0;
export type BsMeta = BookMeta;
export type BsIntroSegment = IntroSegment;
export type BsChapter = Chapter;
export type BsSidebarItem = SidebarItem;
export type BsVerse = VerseRecord;
// ---------------------------------------------------------------------------
// Text title lookup
// Data accessors
// ---------------------------------------------------------------------------
const TEXT_TITLES: Record<number, string> = {
1: "Text 1",
2: "Text 2",
3: "Text 3",
4: "Text 4",
5: "Text 5",
6: "Text 6",
7: "Text 7",
8: "Text 8",
9: "Text 9",
10: "Text 10",
11: "Text 11",
12: "Text 12",
13: "Text 13",
14: "Text 14",
15: "Text 15",
16: "Text 16",
17: "Text 17",
18: "Text 18",
19: "Text 19",
20: "Text 20",
21: "Text 21",
22: "Text 22",
23: "Text 23",
24: "Text 24",
25: "Text 25",
26: "Text 26",
27: "Text 27",
28: "Text 28",
29: "Text 29",
30: "Text 30",
31: "Text 31",
32: "Text 32",
33: "Text 33",
34: "Text 34",
35: "Text 35",
36: "Text 36",
37: "Text 37",
38: "Text 38",
39: "Text 39",
40: "Text 40",
41: "Text 41",
42: "Text 42",
43: "Text 43",
44: "Text 44",
45: "Text 45",
46: "Text 46",
47: "Text 47",
48: "Text 48",
49: "Text 49",
50: "Text 50",
51: "Text 51",
52: "Text 52",
53: "Text 53",
54: "Text 54",
55: "Text 55",
56: "Text 56",
57: "Text 57",
58: "Text 58",
59: "Text 59",
60: "Text 60",
61: "Text 61",
62: "Text 62",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BsMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: BsTextEntry[];
};
export type BsTextEntry = {
number: number;
title: string;
slug: string;
};
export type BsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type BsVerse = {
book: string;
book_title: string;
text: number; // 1-62
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 BsSidebarItem = {
/** URL-safe slug: "intro-introduction", "intro-foreword", "1" … "62" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Text number for verses, or null for intro segments */
text: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/iso.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;
export async function getBsMeta() {
return getBookMeta(CODE);
}
export async function getBsIntroSegments() {
return getIntroSegments(CODE);
}
export async function getBsIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
/** 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 (string[] from Prisma) via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, texts list, etc.)
*/
export async function getBsMeta(): Promise<BsMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as BsMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getBsIntroSegments(): Promise<BsIntroSegment[]> {
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-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getBsIntroSegment(
slug: string,
): Promise<BsIntroSegment | 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 (from verses collection)
// ---------------------------------------------------------------------------
/**
* A single verse/text by slug.
*
* - "1"-"62" → chapter=1–62, verse=1
*
* Verse stored as chapter=text_number, verse=1 in the verses collection.
*/
export async function getBsVerse(slug: string): Promise<BsVerse | null> {
const num = Number(slug);
if (!Number.isInteger(num) || num < 1 || num > 62) return null;
const textNum = num;
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),
};
const num = parseInt(slug, 10);
if (isNaN(num)) return null;
return getSingleVerse(CODE, null, 5, num);
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-introduction" → intro segment
* "intro-foreword" → intro segment
* "1" … "62" → verse (text)
*/
export async function getBsContent(
slug: string,
): Promise<BsVerse | BsIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getBsIntroSegment(slug);
): Promise<BsVerse | BsIntroSegment | BsChapter | null> {
// Try as verse first (numeric slug)
if (/^\d+$/.test(slug)) {
const verse = await getBsVerse(slug);
if (verse) return verse;
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 62) {
return getBsVerse(String(num));
// Fall back to prose content (intro segments, etc.)
return getContent(CODE, slug);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: Introduction → Foreword → Texts 1-62
*/
export async function getBsSidebarItems(): Promise<BsSidebarItem[]> {
const items: BsSidebarItem[] = [];
// 1. Introduction intro segment
items.push({
slug: "intro-introduction",
label: "Introduction",
type: "intro-segment",
text: null,
});
// 2. Foreword intro segment
items.push({
slug: "intro-foreword",
label: "Foreword",
type: "intro-segment",
text: null,
});
// 3. Texts 1-62 (verses)
for (let i = 1; i <= 62; i++) {
items.push({
slug: String(i),
label: `${i}. ${TEXT_TITLES[i]}`,
type: "verse",
text: i,
});
}
return items;
export async function getBsSidebarItems() {
return getSidebarItems(CODE);
}
+22 -292
View File
@@ -1,299 +1,29 @@
/**
* Data access layer for Śrī Caitanya-bhāgavata Commentary (CBHC).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* CBHC is a simple prose book with no volume structure and no intro segments —
* just 4 chapters directly. 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/cbhc/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import {
getBookMeta,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
const CODE = "cbhc";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export type CbhcMeta = BookMeta;
export type CbhcChapterEntry = { number: number; title: string };
export type CbhcChapter = Chapter;
export type CbhcSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "cbhc";
const CBHC_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "cbhc");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CbhcMeta = {
book: string;
title: string;
author: string;
year?: number | null;
description: string;
totalChapters: number;
chapters: CbhcChapterEntry[];
};
export type CbhcChapterEntry = {
number: number;
title: string;
};
export type CbhcChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type CbhcSidebarItem = {
/** URL-safe slug: "1", "2", "3", "4" */
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;
export async function getCbhcMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getCbhcChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getCbhcContent(slug: string) {
return getContent(CODE, slug);
}
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): CbhcChapter | null {
const filePath = path.join(
CBHC_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<CbhcChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getCbhcMeta(): Promise<CbhcMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as CbhcMeta;
// Fallback to local JSON
const introPath = path.join(CBHC_JSON_DIR, "intro.json");
return readLocalJson<CbhcMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getCbhcChapter(
chapterNum: number,
): Promise<CbhcChapter | null> {
if (chapterNum < 1 || chapterNum > 4 || !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" … "4" → chapter
*/
export async function getCbhcContent(
slug: string,
): Promise<CbhcChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getCbhcChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–4 (no intro segments)
*/
export async function getCbhcSidebarItems(): Promise<CbhcSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getCbhcMeta(),
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);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(meta: CbhcMeta): CbhcSidebarItem[] {
const items: CbhcSidebarItem[] = [];
// Chapters 1–4 (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;
}
function buildSidebarFromLocal(meta: CbhcMeta | null): CbhcSidebarItem[] {
const items: CbhcSidebarItem[] = [];
// 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: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(CBHC_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<CbhcChapter>(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,
});
}
}
}
}
return items;
export async function getCbhcSidebarItems() {
return getSidebarItems(CODE);
}
-11
View File
@@ -1,11 +0,0 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+113 -131
View File
@@ -2,14 +2,14 @@
* Data access layer for Disciples.
*
* Provides queries for browsing disciples by letter, place, year, and search.
* Uses the Prisma Disciple model (collection: disciples).
* Uses the BFF API instead of direct Prisma queries.
*
* Disciples have an initiatedName and suffix (e.g., "Dasa", "Devi Dasi", "Sw").
* The letter index aggregates disciples by the first letter of their initiatedName.
* Place and year groupings allow browsing by initiation location and year.
*/
import { prisma } from "@/lib/db";
import { bff, bffList } from "@/lib/bff";
// Types
@@ -37,26 +37,51 @@ export type YearGroup = {
count: number;
};
// --- Summary select clause (reused across queries) ---
const discipleSummarySelect = {
id: true,
initiatedName: true,
suffix: true,
initiationDate: true,
initiationPlace: true,
isVerified: true,
} as const;
// --- Suffix filter type ---
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
// --- BFF response types (snake_case from Go API) ---
type BffDisciple = {
id: string;
initiated_name: string;
suffix: string;
initiation_date: string | null;
initiation_place: string | null;
karmi_name: string | null;
is_verified: boolean;
};
type BffLetterCount = {
_id: string;
count: number;
};
type BffSuffixCount = {
_id: string;
count: number;
};
// --- Field mapping helper ---
function mapDisciple(d: BffDisciple): DiscipleSummary {
return {
id: d.id,
initiatedName: d.initiated_name,
suffix: d.suffix,
initiationDate: d.initiation_date ? new Date(d.initiation_date) : null,
initiationPlace: d.initiation_place ?? null,
isVerified: d.is_verified,
};
}
// --- Count ---
/** Get total number of disciples in the collection. */
export async function getTotalDiscipleCount(): Promise<number> {
return prisma.disciple.count();
const result = await bff<{ total: number }>("/disciples/count");
return result?.total ?? 0;
}
/** Get count of disciples matching optional letter + suffix filters. */
@@ -64,14 +89,19 @@ export async function getFilteredDiscipleCount(
letter: string | null,
suffix: SuffixFilter = "all",
): Promise<number> {
const where: Record<string, unknown> = {};
if (letter) {
where.initiatedName = { startsWith: letter, mode: "insensitive" };
const params: Record<string, string> = {};
if (letter) params.letter = letter;
if (suffix !== "all") params.suffix = suffix;
// If no filters, just use total count endpoint
if (!letter && suffix === "all") {
return getTotalDiscipleCount();
}
if (suffix !== "all") {
where.suffix = suffix;
}
return prisma.disciple.count({ where });
// Fetch filtered list and count client-side
// TODO: Consider adding a dedicated BFF count endpoint with filter params for better performance
const disciples = await bffList<BffDisciple>("/disciples", { params });
return disciples.length;
}
// --- Letter-based queries ---
@@ -79,23 +109,16 @@ export async function getFilteredDiscipleCount(
/**
* Get all disciples whose initiatedName starts with the given letter.
*
* Uses Prisma's `startsWith` filter with case-insensitive mode.
* Results are ordered by initiatedName ascending.
* Uses BFF endpoint with letter query param.
* Results are ordered by initiatedName ascending (handled by BFF).
*/
export async function getDisciplesByLetter(
letter: string,
): Promise<DiscipleSummary[]> {
const disciples = await prisma.disciple.findMany({
where: {
initiatedName: {
startsWith: letter,
mode: "insensitive",
},
},
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
const disciples = await bffList<BffDisciple>("/disciples", {
params: { letter },
});
return disciples;
return disciples.map(mapDisciple);
}
/**
@@ -107,16 +130,11 @@ export async function getDisciplesByLetter(
export async function getAllDisciples(
suffix: SuffixFilter = "all",
): Promise<DiscipleSummary[]> {
const where: Record<string, unknown> = {};
if (suffix !== "all") {
where.suffix = suffix;
}
const disciples = await prisma.disciple.findMany({
where,
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
});
return disciples;
const params: Record<string, string> = {};
if (suffix !== "all") params.suffix = suffix;
const disciples = await bffList<BffDisciple>("/disciples", { params });
return disciples.map(mapDisciple);
}
/**
@@ -133,19 +151,12 @@ export async function getFilteredDisciples(
letter: string | null,
suffix: SuffixFilter = "all",
): Promise<DiscipleSummary[]> {
const where: Record<string, unknown> = {};
if (letter) {
where.initiatedName = { startsWith: letter, mode: "insensitive" };
}
if (suffix !== "all") {
where.suffix = suffix;
}
const disciples = await prisma.disciple.findMany({
where,
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
});
return disciples;
const params: Record<string, string> = {};
if (letter) params.letter = letter;
if (suffix !== "all") params.suffix = suffix;
const disciples = await bffList<BffDisciple>("/disciples", { params });
return disciples.map(mapDisciple);
}
/**
@@ -156,42 +167,22 @@ export async function getFilteredDisciples(
export async function getDiscipleSuffixCounts(): Promise<
{ suffix: string; count: number }[]
> {
const disciples = await prisma.disciple.findMany({
select: { suffix: true },
});
const counts: Record<string, number> = {};
for (const d of disciples) {
counts[d.suffix] = (counts[d.suffix] || 0) + 1;
}
return Object.entries(counts)
.map(([suffix, count]) => ({ suffix, count }))
const suffixes = await bffList<BffSuffixCount>("/disciples/suffixes");
return suffixes
.map((s) => ({ suffix: s._id, count: s.count }))
.sort((a, b) => b.count - a.count);
}
/**
* Get counts of disciples grouped by the first letter of their initiatedName.
*
* Fetches all initiatedName values and aggregates first letters in JavaScript
* (Prisma/MongoDB doesn't support substring aggregations easily).
* Uses the BFF /disciples/letters endpoint which returns pre-aggregated data.
* Returns results sorted alphabetically by letter.
*/
export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
const disciples = await prisma.disciple.findMany({
select: { initiatedName: true },
});
const counts: Record<string, number> = {};
for (const d of disciples) {
if (d.initiatedName && d.initiatedName.length > 0) {
const letter = d.initiatedName[0].toUpperCase();
counts[letter] = (counts[letter] || 0) + 1;
}
}
return Object.entries(counts)
.map(([letter, count]) => ({ letter, count }))
const letters = await bffList<BffLetterCount>("/disciples/letters");
return letters
.map((l) => ({ letter: l._id, count: l.count }))
.sort((a, b) => a.letter.localeCompare(b.letter));
}
@@ -200,19 +191,18 @@ export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
/**
* Get unique initiation places with disciple counts.
*
* Fetches all non-null initiationPlace values and aggregates in JavaScript.
* No dedicated BFF endpoint — fetches all disciples and aggregates in JS.
* Returns results sorted by count descending.
*
* TODO: Add a BFF endpoint (GET /api/v1/disciples/places) for better performance.
*/
export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
const disciples = await prisma.disciple.findMany({
where: { initiationPlace: { not: null } },
select: { initiationPlace: true },
});
const disciples = await bffList<BffDisciple>("/disciples");
const counts: Record<string, number> = {};
for (const d of disciples) {
if (d.initiationPlace) {
counts[d.initiationPlace] = (counts[d.initiationPlace] || 0) + 1;
if (d.initiation_place) {
counts[d.initiation_place] = (counts[d.initiation_place] || 0) + 1;
}
}
@@ -224,17 +214,20 @@ export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
/**
* Get all disciples initiated at a given place.
*
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
* Results are ordered by initiatedName ascending.
*
* TODO: Add BFF query param support (GET /api/v1/disciples?place=...) for better performance.
*/
export async function getDisciplesByPlace(
place: string,
): Promise<DiscipleSummary[]> {
const disciples = await prisma.disciple.findMany({
where: { initiationPlace: place },
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
});
return disciples;
const disciples = await bffList<BffDisciple>("/disciples");
return disciples
.filter((d) => d.initiation_place === place)
.map(mapDisciple)
.sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
}
// --- Year-based queries ---
@@ -242,23 +235,23 @@ export async function getDisciplesByPlace(
/**
* Get unique initiation years with disciple counts.
*
* Fetches all non-null initiationDate values and aggregates years in JavaScript
* (Prisma/MongoDB doesn't support date-part aggregations easily).
* No dedicated BFF endpoint — fetches all disciples and aggregates years in JS.
* Returns results sorted by year ascending.
*
* TODO: Add a BFF endpoint (GET /api/v1/disciples/years) for better performance.
*/
export async function getDiscipleYears(): Promise<YearGroup[]> {
const disciples = await prisma.disciple.findMany({
where: { initiationDate: { not: null } },
select: { initiationDate: true },
});
const disciples = await bffList<BffDisciple>("/disciples");
const counts: Record<number, number> = {};
for (const d of disciples) {
if (d.initiationDate) {
const year = d.initiationDate.getFullYear();
if (d.initiation_date) {
const year = new Date(d.initiation_date).getFullYear();
if (!isNaN(year)) {
counts[year] = (counts[year] || 0) + 1;
}
}
}
return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count }))
@@ -268,27 +261,24 @@ export async function getDiscipleYears(): Promise<YearGroup[]> {
/**
* Get all disciples initiated in a given year.
*
* Uses a date range query (gte: Jan 1 of year, lt: Jan 1 of year+1)
* to match all initiationDate values within the calendar year.
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
* Results are ordered by initiatedName ascending.
*
* TODO: Add BFF query param support (GET /api/v1/disciples?year=...) for better performance.
*/
export async function getDisciplesByYear(
year: number,
): Promise<DiscipleSummary[]> {
const startOfYear = new Date(year, 0, 1);
const startOfNextYear = new Date(year + 1, 0, 1);
const disciples = await bffList<BffDisciple>("/disciples");
const disciples = await prisma.disciple.findMany({
where: {
initiationDate: {
gte: startOfYear,
lt: startOfNextYear,
},
},
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
});
return disciples;
return disciples
.filter((d) => {
if (!d.initiation_date) return false;
const dYear = new Date(d.initiation_date).getFullYear();
return dYear === year;
})
.map(mapDisciple)
.sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
}
// --- Search ---
@@ -296,22 +286,14 @@ export async function getDisciplesByYear(
/**
* Search disciples by initiatedName using case-insensitive matching.
*
* Uses Prisma's `contains` filter with case-insensitive mode.
* Uses BFF search endpoint.
* Results are limited to 100 and ordered by initiatedName ascending.
*/
export async function searchDisciples(
query: string,
): Promise<DiscipleSummary[]> {
const disciples = await prisma.disciple.findMany({
where: {
initiatedName: {
contains: query,
mode: "insensitive",
},
},
orderBy: { initiatedName: "asc" },
take: 100,
select: discipleSummarySelect,
const disciples = await bffList<BffDisciple>("/disciples/search", {
params: { q: query },
});
return disciples;
return disciples.map(mapDisciple);
}
+33 -311
View File
@@ -1,325 +1,47 @@
/**
* Data access layer for Dialectic Spiritualism (DS).
*
* Reads from MongoDB via Prisma.
* DS is a large prose/dialogue book with 3 intro segments (front-pages,
* foreword, preface) and 13 parent chapters, each containing philosopher
* sub-chapters (e.g., "1-1" Socrates, "1-2" Plato, "1-3" Aristotle).
* 38 philosopher sub-chapters total.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
getNavigableItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "ds";
const BOOK = "ds";
export type DsMeta = BookMeta;
export type DsSubChapterEntry = { slug: string; title: string };
export type DsIntroSegment = IntroSegment;
export type DsChapter = Chapter;
export type DsSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DsMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: DsParentChapter[];
};
export type DsParentChapter = {
number: number;
title: string;
subChapters: DsSubChapterEntry[];
};
export type DsSubChapterEntry = {
slug: string; // "1-1", "1-2", etc.
title: string;
};
export type DsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type DsChapter = {
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 DsSidebarItem = {
/** 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(/<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;
export async function getDsMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getDsIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getDsIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getDsChapter(slug: string) {
return getChapter(CODE, slug);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
*/
export async function getDsMeta(): Promise<DsMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as DsMeta;
export async function getDsContent(slug: string) {
return getContent(CODE, slug);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getDsIntroSegments(): Promise<DsIntroSegment[]> {
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 getDsIntroSegment(
slug: string,
): Promise<DsIntroSegment | 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 (sub-chapters like "1-1", "2-3", etc.)
// ---------------------------------------------------------------------------
/**
* A sub-chapter by its slug (e.g. "1-1", "2-3").
*/
export async function getDsChapter(slug: string): Promise<DsChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug,
},
},
});
if (!doc) return null;
// 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 getDsMeta();
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),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-foreword" → intro segment
* "intro-preface" → intro segment
* "1-1", "2-3", etc. → sub-chapter
*/
export async function getDsContent(
slug: string,
): Promise<DsChapter | DsIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getDsIntroSegment(slug);
}
// Sub-chapter slug like "1-1", "2-3"
return getDsChapter(slug);
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list with hierarchical structure.
*
* Order: front-pages → foreword → preface
* → parent-1 (label) → 1-1, 1-2, 1-3 → parent-2 (label) → 2-1, 2-2, ...
*
* "parent" type items are non-clickable headings. Only "intro-segment" and
* "sub-chapter" items are navigable pages.
*/
export async function getDsSidebarItems(): Promise<DsSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getDsMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
if (!meta) return [];
const items: DsSidebarItem[] = [];
// Intro segments (all come before chapters — no back matter for DS)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
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: `${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,
});
}
}
return items;
}
/**
* Get the flat list of navigable sidebar items (excludes "parent" headings).
* Used for prev/next navigation.
*/
export function getNavigableItems(items: DsSidebarItem[]): DsSidebarItem[] {
return items.filter((item) => item.type !== "parent");
export async function getDsSidebarItems() {
return getSidebarItems(CODE);
}
export { getNavigableItems };
+26 -251
View File
@@ -1,282 +1,57 @@
/**
* Data access layer for Easy Journey to Other Planets (EJ).
*
* Reads from MongoDB via Prisma.
* EJ is a simple prose book with 2 chapters and 2 intro segments.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "ej";
const BOOK = "ej";
// Re-export types with book-specific names for backwards compatibility
export type EjMeta = BookMeta;
export type EjChapterEntry = { number: number; title: string };
export type EjIntroSegment = IntroSegment;
export type EjChapter = Chapter;
export type EjSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EjMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: EjChapterEntry[];
};
export type EjChapterEntry = {
number: number;
title: string;
};
export type EjIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type EjChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type EjSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", "2" */
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/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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getEjMeta(): Promise<EjMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as EjMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getEjIntroSegment(
slug: string,
): Promise<EjIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–2).
*/
export async function getEjChapter(
chapterNum: number,
): Promise<EjChapter | null> {
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "intro-preface" → intro segment
* "1", "2" → chapter
*/
export async function getEjContent(
slug: string,
): Promise<EjChapter | EjIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getEjIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
return getEjChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → chapters 1–2
*/
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getEjMeta(),
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: EjSidebarItem[] = [];
// 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–2 (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;
return getSidebarItems(CODE);
}
+26 -249
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
*
* Reads from MongoDB via Prisma.
* EK is a simple prose book with 1 intro segment (front-pages) and 6 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "ek";
const BOOK = "ek";
// Re-export types with book-specific names for backwards compatibility
export type EkMeta = BookMeta;
export type EkChapterEntry = { number: number; title: string };
export type EkIntroSegment = IntroSegment;
export type EkChapter = Chapter;
export type EkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EkMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: EkChapterEntry[];
};
export type EkChapterEntry = {
number: number;
title: string;
};
export type EkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type EkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type EkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "6" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getEkMeta(): Promise<EkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as EkMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getEkIntroSegment(
slug: string,
): Promise<EkIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–6).
*/
export async function getEkChapter(
chapterNum: number,
): Promise<EkChapter | null> {
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "6" → chapter
*/
export async function getEkContent(
slug: string,
): Promise<EkChapter | EkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getEkIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
return getEkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–6
*/
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getEkMeta(),
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: EkSidebarItem[] = [];
// 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–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;
return getSidebarItems(CODE);
}
+30 -434
View File
@@ -1,443 +1,39 @@
/**
* Data access layer for Gītār Gān (GG).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* GG is a prose/poetry 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/gg/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
const CODE = "gg";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export type GgMeta = BookMeta;
export type GgChapterEntry = { number: number; title: string };
export type GgIntroSegment = IntroSegment;
export type GgChapter = Chapter;
export type GgSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "gg";
const GG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "gg");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type GgMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: GgChapterEntry[];
};
export type GgChapterEntry = {
number: number;
title: string;
};
export type GgIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type GgChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type GgSidebarItem = {
/** URL-safe slug: "intro-introduction", "1", "18" */
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;
export async function getGgMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getGgIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getGgIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getGgChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getGgContent(slug: string) {
return getContent(CODE, slug);
}
}
function getLocalIntroSegments(): GgIntroSegment[] {
const introDir = path.join(GG_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: GgIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<GgIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<GgIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): GgChapter | null {
const filePath = path.join(
GG_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<GgChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getGgMeta(): Promise<GgMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as GgMeta;
// Fallback to local JSON
const introPath = path.join(GG_JSON_DIR, "intro.json");
return readLocalJson<GgMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getGgIntroSegments(): Promise<GgIntroSegment[]> {
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-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getGgIntroSegment(
slug: string,
): Promise<GgIntroSegment | null> {
// 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 getGgChapter(
chapterNum: number,
): Promise<GgChapter | null> {
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-introduction" → intro segment
* "1" … "18" → chapter
*/
export async function getGgContent(
slug: string,
): Promise<GgChapter | GgIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getGgIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getGgChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: introduction → chapters 1–18
*/
export async function getGgSidebarItems(): Promise<GgSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getGgMeta(),
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: GgMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): GgSidebarItem[] {
const items: GgSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
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 >= 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: GgMeta | null): GgSidebarItem[] {
const items: GgSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
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(GG_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<GgChapter>(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
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
export async function getGgSidebarItems() {
return getSidebarItems(CODE);
}
+32 -269
View File
@@ -1,282 +1,45 @@
/**
* Data access layer for The Kṛṣṇa Consciousness Handbook (HKH).
* Data access layer for The Hare Kṛṣṇa Handbook (HKH).
*
* Reads from MongoDB via Prisma.
* HKH is a prose book with 2 intro segments (front-pages, introduction)
* and 19 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "hkh";
const BOOK = "hkh";
export type HkhMeta = BookMeta;
export type HkhChapterEntry = { number: number; title: string };
export type HkhIntroSegment = IntroSegment;
export type HkhChapter = Chapter;
export type HkhSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type HkhMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: HkhChapterEntry[];
};
export type HkhChapterEntry = {
number: number;
title: string;
};
export type HkhIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type HkhChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type HkhSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-introduction", "1", "2", … "7" */
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
// ---------------------------------------------------------------------------
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;
export async function getHkhMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getHkhIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getHkhIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getHkhChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getHkhMeta(): Promise<HkhMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as HkhMeta;
export async function getHkhContent(slug: string) {
return getContent(CODE, slug);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getHkhIntroSegments(): Promise<HkhIntroSegment[]> {
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 getHkhIntroSegment(
slug: string,
): Promise<HkhIntroSegment | 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–19).
*/
export async function getHkhChapter(
chapterNum: number,
): Promise<HkhChapter | null> {
if (chapterNum < 1 || chapterNum > 19 || !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-pages" → intro segment
* "intro-introduction" → intro segment
* "1" … "7" → chapter
*/
export async function getHkhContent(
slug: string,
): Promise<HkhChapter | HkhIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getHkhIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 19) {
return getHkhChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–19
*/
export async function getHkhSidebarItems(): Promise<HkhSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getHkhMeta(),
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: HkhSidebarItem[] = [];
// 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–19 (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;
export async function getHkhSidebarItems() {
return getSidebarItems(CODE);
}
+41 -313
View File
@@ -1,225 +1,47 @@
/**
* Data access layer for Śrī Īśopaniṣad (ISO).
*
* Reads from MongoDB via Prisma.
* ISO is unique — it has BOTH verses (in the `verses` collection)
* AND prose intro segments (in the `prose_chapters` collection).
*
* Verses were seeded with:
* - book: "iso", division: null
* - chapter = mantra number (0 for Invocation, 1-18 for mantras)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "iso", volume: 0, type: "intro-segment"
* - slugs like "intro-front-pages", "intro-introduction", "intro-about-the-author"
* Delegates to the generic book data layer (BFF API) for prose intro segments,
* and to lib/api.ts for verse (mantra) lookups.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { getSingleVerse, type VerseRecord } from "@/lib/api";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "iso";
const BOOK = "iso";
const NO_VOLUME = 0;
export type IsoMeta = BookMeta;
export type IsoIntroSegment = IntroSegment;
export type IsoChapter = Chapter;
export type IsoSidebarItem = SidebarItem;
export type IsoVerse = VerseRecord;
// ---------------------------------------------------------------------------
// Mantra title lookup
// ---------------------------------------------------------------------------
const MANTRA_TITLES: Record<number, string> = {
0: "Invocation",
1: "Mantra One",
2: "Mantra Two",
3: "Mantra Three",
4: "Mantra Four",
5: "Mantra Five",
6: "Mantra Six",
7: "Mantra Seven",
8: "Mantra Eight",
9: "Mantra Nine",
10: "Mantra Ten",
11: "Mantra Eleven",
12: "Mantra Twelve",
13: "Mantra Thirteen",
14: "Mantra Fourteen",
15: "Mantra Fifteen",
16: "Mantra Sixteen",
17: "Mantra Seventeen",
18: "Mantra Eighteen",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type IsoMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalMantras: number;
mantras: IsoMantraEntry[];
};
export type IsoMantraEntry = {
number: number;
title: string;
slug: string;
};
export type IsoIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type IsoVerse = {
book: string;
book_title: string;
mantra: 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 IsoSidebarItem = {
/** URL-safe slug: "intro-front-pages", "invocation", "1", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Mantra number (0 for invocation), or null for non-verse items */
mantra: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors 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;
export async function getIsoMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getIsoIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getIsoIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
}
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, mantras list, etc.)
*/
export async function getIsoMeta(): Promise<IsoMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as IsoMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getIsoIntroSegments(): Promise<IsoIntroSegment[]> {
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 getIsoIntroSegment(
slug: string,
): Promise<IsoIntroSegment | 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 (from verses collection)
// ---------------------------------------------------------------------------
/**
* A single verse/mantra by slug.
*
* - "invocation" → chapter=0, verse=1
* - "1"-"18" → chapter=1–18, verse=1
* ISO verses are stored with: book "iso", no division,
* chapter = mantra number (0 for invocation), verse = 1.
*/
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
let mantraNum: number;
@@ -227,119 +49,25 @@ export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
if (slug === "invocation") {
mantraNum = 0;
} else {
const num = Number(slug);
if (!Number.isInteger(num) || num < 1 || num > 18) return null;
const num = parseInt(slug, 10);
if (isNaN(num) || num < 1 || num > 18) return null;
mantraNum = num;
}
const doc = await prisma.verse.findFirst({
where: { book: BOOK, division: null, chapter: mantraNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
mantra: doc.chapter, // chapter field stores the mantra number
title: MANTRA_TITLES[doc.chapter] ?? `Mantra ${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),
};
return getSingleVerse(CODE, null, mantraNum, 1);
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "invocation" → invocation verse
* "1" … "18" → mantra verse
*/
export async function getIsoContent(
slug: string,
): Promise<IsoVerse | IsoIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getIsoIntroSegment(slug);
): Promise<IsoVerse | IsoIntroSegment | IsoChapter | null> {
// Try as verse/mantra first (numeric slug or "invocation")
if (slug === "invocation" || /^\d+$/.test(slug)) {
const verse = await getIsoVerse(slug);
if (verse) return verse;
}
if (slug === "invocation") {
return getIsoVerse("invocation");
// Fall back to prose content (intro segments, etc.)
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getIsoVerse(String(num));
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → invocation → mantras 1-18 → about-the-author
*/
export async function getIsoSidebarItems(): Promise<IsoSidebarItem[]> {
const meta = await getIsoMeta();
if (!meta) return [];
const items: IsoSidebarItem[] = [];
// 1. Front pages intro segment
items.push({
slug: "intro-front-pages",
label: "Back Cover & Front Pages",
type: "intro-segment",
mantra: null,
});
// 2. Introduction (Teachings of the Vedas)
items.push({
slug: "intro-introduction",
label: "Introduction (Teachings of the Vedas)",
type: "intro-segment",
mantra: null,
});
// 3. Invocation + Mantras 1-18 (from meta.mantras)
for (const m of meta.mantras) {
if (m.number === 0) {
items.push({
slug: "invocation",
label: "Invocation",
type: "verse",
mantra: 0,
});
} else {
items.push({
slug: String(m.number),
label: `${m.number}. ${m.title}`,
type: "verse",
mantra: m.number,
});
}
}
// 4. About the Author intro segment (at the end)
items.push({
slug: "intro-about-the-author",
label: "About the Author",
type: "intro-segment",
mantra: null,
});
return items;
export async function getIsoSidebarItems() {
return getSidebarItems(CODE);
}
+82 -35
View File
@@ -1,13 +1,11 @@
/**
* Data access layer for Journals.
*
* Uses dedicated Prisma models:
* - JournalMeta (collection: journal_meta)
* - JournalChapter (collection: journal_chapters)
* Uses the BFF API instead of direct Prisma queries.
* Falls back to local JSON files when BFF is unavailable.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList } from "@/lib/bff";
import fs from "fs";
import path from "path";
@@ -65,6 +63,31 @@ export type JournalSidebarItem = {
chapter: number;
};
// --- BFF response types ---
type BffJournalMeta = {
id: string;
book: string;
data: {
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
chapters: JournalChapterEntry[];
};
};
type BffJournalChapter = {
book: string;
chapter: number;
title: string;
type: string;
slug: string;
paragraphs: string[];
paragraphs_json?: string;
};
// HTML sanitisation
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
@@ -80,7 +103,7 @@ function sanitizeHtml(html: string): string {
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
@@ -97,6 +120,51 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
return result.map(sanitizeHtml);
}
// --- BFF → domain mappers ---
function mapBffMetaToJournalMetaData(raw: BffJournalMeta): JournalMetaData {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalChapters: raw.data.totalChapters,
chapters: raw.data.chapters,
};
}
function mapBffMetaToSummary(raw: BffJournalMeta): JournalSummary {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalChapters: raw.data.totalChapters,
};
}
function mapBffChapter(raw: BffJournalChapter): JournalChapterData {
// Handle paragraphs_json fallback
let paragraphs = raw.paragraphs;
if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) {
try {
paragraphs = JSON.parse(raw.paragraphs_json);
} catch {
paragraphs = [];
}
}
return {
book: raw.book,
chapter: raw.chapter,
title: raw.title,
type: raw.type,
paragraphs: normalizeParagraphs(paragraphs),
};
}
// Local JSON fallback helpers
function findLocalDirForBook(bookCode: string): string | null {
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
@@ -160,21 +228,9 @@ function getLocalChapterBySlug(
// Public API
export async function getAllJournals(): Promise<JournalSummary[]> {
try {
const results = await prisma.journalMeta.findMany({
orderBy: { book: "asc" },
});
const results = await bffList<BffJournalMeta>("/journals");
if (results.length > 0) {
return results.map((row) => {
const data = row.data as unknown as JournalMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters,
};
});
return results.map(mapBffMetaToSummary);
}
} catch {
/* fall through */
@@ -194,10 +250,8 @@ export async function getJournalMeta(
bookCode: string,
): Promise<JournalMetaData | null> {
try {
const row = await prisma.journalMeta.findUnique({
where: { book: bookCode },
});
if (row) return row.data as unknown as JournalMetaData;
const raw = await bff<BffJournalMeta>(`/journals/${bookCode}`);
if (raw) return mapBffMetaToJournalMetaData(raw);
} catch {
/* fall through */
}
@@ -210,17 +264,10 @@ export async function getJournalChapterBySlug(
): Promise<JournalChapterData | null> {
if (!slug) return null;
try {
const doc = await prisma.journalChapter.findUnique({
where: { book_slug: { book: bookCode, slug } },
});
if (doc)
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
const raw = await bff<BffJournalChapter>(
`/journals/${bookCode}/chapters/${slug}`,
);
if (raw) return mapBffChapter(raw);
} catch {
/* fall through */
}
+31 -431
View File
@@ -1,445 +1,45 @@
/**
* Data access layer for Life Comes from Life (LCL).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* LCL is a prose/dialogue 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/lcl/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "lcl";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type LclMeta = BookMeta;
export type LclChapterEntry = { number: number; title: string };
export type LclIntroSegment = IntroSegment;
export type LclChapter = Chapter;
export type LclSidebarItem = SidebarItem;
const BOOK = "lcl";
const LCL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lcl");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LclMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LclChapterEntry[];
};
export type LclChapterEntry = {
number: number;
title: string;
};
export type LclIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LclChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LclSidebarItem = {
/** URL-safe slug: "intro-foreword", "1", "16", "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(/<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;
export async function getLclMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getLclIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getLclIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getLclChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getLclContent(slug: string) {
return getContent(CODE, slug);
}
}
function getLocalIntroSegments(): LclIntroSegment[] {
const introDir = path.join(LCL_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LclIntroSegment[] = [];
const files = ["foreword.json", "introduction.json", "about-the-author.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LclIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LclIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LclChapter | null {
const filePath = path.join(
LCL_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LclChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLclMeta(): Promise<LclMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LclMeta;
// Fallback to local JSON
const introPath = path.join(LCL_JSON_DIR, "intro.json");
return readLocalJson<LclMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLclIntroSegments(): Promise<LclIntroSegment[]> {
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 getLclIntroSegment(
slug: string,
): Promise<LclIntroSegment | null> {
// 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–16).
*/
export async function getLclChapter(
chapterNum: number,
): Promise<LclChapter | null> {
if (chapterNum < 1 || chapterNum > 16 || !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-foreword" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "16" → chapter
*/
export async function getLclContent(
slug: string,
): Promise<LclChapter | LclIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLclIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 16) {
return getLclChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: foreword → introduction → chapters 1–16 → about-the-author
*/
export async function getLclSidebarItems(): Promise<LclSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLclMeta(),
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: LclMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–16 (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 >= 99, e.g. about-the-author)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: LclMeta | null): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
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-16
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(LCL_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<LclChapter>(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)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
export async function getLclSidebarItems() {
return getSidebarItems(CODE);
}
+135 -121
View File
@@ -2,7 +2,7 @@
* Data access layer for Letters.
*
* Provides queries for browsing letters by year, recipient, and location.
* Uses the Prisma Letter model (collection: letters).
* Calls the BFF API for all data access (no direct Prisma usage).
*
* Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]).
* The recipient list aggregates individual names across all letters,
@@ -10,9 +10,12 @@
* ANY recipient (individual or group).
*/
import { prisma } from "@/lib/db";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LetterSummary = {
slug: string;
recipients: string[];
@@ -52,7 +55,43 @@ export type LocationGroup = {
count: number;
};
// Sanitize
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffLetterSummary = {
slug: string;
recipients: string[];
recipient_display: string;
date: string | null;
year: number | null;
month: number | null;
location: string | null;
};
type BffLetterFull = {
slug: string;
recipients: string[];
recipient_display: string;
date: string | null;
date_ref: string | null;
year: number | null;
month: number | null;
day: number | null;
location: string | null;
salutation: string | null;
paragraphs: string[];
};
type BffAggregation = {
_id: string | number;
count: number;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
@@ -65,23 +104,60 @@ function sanitizeHtml(html: string): string {
return clean;
}
// --- Index/overview queries ---
// ---------------------------------------------------------------------------
// Mappers (BFF snake_case → frontend camelCase)
// ---------------------------------------------------------------------------
function mapLetterSummary(item: BffLetterSummary): LetterSummary {
return {
slug: item.slug,
recipients: item.recipients ?? [],
recipientDisplay: item.recipient_display ?? "",
date: item.date ?? null,
year: item.year ?? null,
month: item.month ?? null,
location: item.location ?? null,
};
}
function mapLetterFull(item: BffLetterFull): LetterFull {
return {
slug: item.slug,
recipients: item.recipients ?? [],
recipientDisplay: item.recipient_display ?? "",
date: item.date ?? null,
dateRef: item.date_ref ?? null,
year: item.year ?? null,
month: item.month ?? null,
day: item.day ?? null,
location: item.location ?? null,
salutation: item.salutation ?? null,
paragraphs: (item.paragraphs ?? []).map(sanitizeHtml),
};
}
function mapYearGroup(item: BffAggregation): YearGroup {
return { year: Number(item._id), count: item.count };
}
function mapRecipientGroup(item: BffAggregation): RecipientGroup {
return { name: String(item._id), count: item.count };
}
function mapLocationGroup(item: BffAggregation): LocationGroup {
return { name: String(item._id), count: item.count };
}
// ---------------------------------------------------------------------------
// Index / overview queries
// ---------------------------------------------------------------------------
export async function getLetterYears(): Promise<YearGroup[]> {
const letters = await prisma.letter.findMany({
where: { year: { not: null } },
select: { year: true },
const raw = await bffList<BffAggregation>("/letters/years", {
tags: ["letters"],
});
const counts: Record<number, number> = {};
for (const l of letters) {
if (l.year) {
counts[l.year] = (counts[l.year] || 0) + 1;
}
}
return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count }))
return raw
.map(mapYearGroup)
.filter((g) => g.year >= 1940 && g.year <= 1980)
.sort((a, b) => a.year - b.year);
}
@@ -90,67 +166,39 @@ export async function getLetterYears(): Promise<YearGroup[]> {
* Get all unique individual recipients with letter counts.
*
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
* This function aggregates across all letters so that "Mukunda" appears
* once with count = total letters where Mukunda is ANY recipient.
* The BFF aggregation endpoint already aggregates across all letters so
* that "Mukunda" appears once with count = total letters where Mukunda
* is ANY recipient.
*/
export async function getLetterRecipients(): Promise<RecipientGroup[]> {
const letters = await prisma.letter.findMany({
select: { recipients: true },
const raw = await bffList<BffAggregation>("/letters/recipients", {
tags: ["letters"],
});
const counts: Record<string, number> = {};
for (const l of letters) {
for (const r of l.recipients) {
counts[r] = (counts[r] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
return raw.map(mapRecipientGroup).sort((a, b) => b.count - a.count);
}
export async function getLetterLocations(): Promise<LocationGroup[]> {
const letters = await prisma.letter.findMany({
where: { location: { not: null } },
select: { location: true },
const raw = await bffList<BffAggregation>("/letters/locations", {
tags: ["letters"],
});
const counts: Record<string, number> = {};
for (const l of letters) {
if (l.location) {
counts[l.location] = (counts[l.location] || 0) + 1;
}
return raw.map(mapLocationGroup).sort((a, b) => b.count - a.count);
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
// --- Letter list queries ---
// ---------------------------------------------------------------------------
// Letter list queries
// ---------------------------------------------------------------------------
export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({
where: { year },
orderBy: [{ month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
const raw = await bffList<BffLetterSummary>(`/letters/year/${year}`, {
tags: ["letters"],
});
return letters;
return raw.map(mapLetterSummary);
}
/**
* Get all letters where the given name is ANY recipient.
*
* Uses Prisma's `has` filter on the recipients array.
* The BFF endpoint handles the array-contains matching server-side.
* Searching for "Mukunda" returns letters addressed to:
* - "Mukunda" (individual)
* - "Mukunda, Janaki" (group containing Mukunda)
@@ -159,63 +207,38 @@ export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
export async function getLettersByRecipient(
recipient: string,
): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({
where: { recipients: { has: recipient } },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
const encoded = encodeURIComponent(recipient);
const raw = await bffList<BffLetterSummary>(`/letters/recipient/${encoded}`, {
tags: ["letters"],
});
return letters;
return raw.map(mapLetterSummary);
}
export async function getLettersByLocation(
location: string,
): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({
where: { location },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
const encoded = encodeURIComponent(location);
const raw = await bffList<BffLetterSummary>(`/letters/location/${encoded}`, {
tags: ["letters"],
});
return letters;
return raw.map(mapLetterSummary);
}
// --- Single letter ---
// ---------------------------------------------------------------------------
// Single letter
// ---------------------------------------------------------------------------
export async function getLetter(slug: string): Promise<LetterFull | null> {
const letter = await prisma.letter.findUnique({ where: { slug } });
const letter = await bff<BffLetterFull>(`/letters/${slug}`, {
tags: ["letters"],
});
if (!letter) return null;
return {
slug: letter.slug,
recipients: letter.recipients,
recipientDisplay: letter.recipientDisplay,
date: letter.date,
dateRef: letter.dateRef,
year: letter.year,
month: letter.month,
day: letter.day,
location: letter.location,
salutation: letter.salutation,
paragraphs: letter.paragraphs.map(sanitizeHtml),
};
return mapLetterFull(letter);
}
// --- Navigation helpers ---
// ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
export async function getAdjacentLetters(
slug: string,
@@ -223,27 +246,18 @@ export async function getAdjacentLetters(
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
if (!year) return { prev: null, next: null };
const yearLetters = await prisma.letter.findMany({
where: { year },
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
});
const letters = await getLettersByYear(year);
const idx = letters.findIndex((l) => l.slug === slug);
const idx = yearLetters.findIndex((l) => l.slug === slug);
return {
prev: idx > 0 ? yearLetters[idx - 1] : null,
next: idx < yearLetters.length - 1 ? yearLetters[idx + 1] : null,
prev: idx > 0 ? letters[idx - 1] : null,
next: idx < letters.length - 1 ? letters[idx + 1] : null,
};
}
export async function getTotalLetterCount(): Promise<number> {
return prisma.letter.count();
const result = await bff<{ count: number }>("/letters", {
tags: ["letters"],
});
return result?.count ?? 0;
}
+32 -416
View File
@@ -1,429 +1,45 @@
/**
* Data access layer for Light of the Bhāgavata (LoB).
* Data access layer for Light of the Bhāgavata (LOB).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* LoB is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* It has 48 "texts" (numbered chapters) plus 4 intro segments
* (front-pages, preface, acknowledgement, the-artist).
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/lob/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "lob";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type LobMeta = BookMeta;
export type LobChapterEntry = { number: number; title: string };
export type LobIntroSegment = IntroSegment;
export type LobChapter = Chapter;
export type LobSidebarItem = SidebarItem;
const BOOK = "lob";
const LOB_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lob");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LobMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LobChapterEntry[];
};
export type LobChapterEntry = {
number: number;
title: string;
};
export type LobIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LobChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LobSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "48", etc. */
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;
export async function getLobMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getLobIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getLobIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getLobChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getLobContent(slug: string) {
return getContent(CODE, slug);
}
}
function getLocalIntroSegments(): LobIntroSegment[] {
const introDir = path.join(LOB_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LobIntroSegment[] = [];
const files = [
"front-pages.json",
"preface.json",
"acknowledgement.json",
"the-artist.json",
];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LobIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LobIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LobChapter | null {
const filePath = path.join(
LOB_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LobChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLobMeta(): Promise<LobMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LobMeta;
// Fallback to local JSON
const introPath = path.join(LOB_JSON_DIR, "intro.json");
return readLocalJson<LobMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLobIntroSegments(): Promise<LobIntroSegment[]> {
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-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getLobIntroSegment(
slug: string,
): Promise<LobIntroSegment | null> {
// 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 (Texts 1–48)
// ---------------------------------------------------------------------------
/**
* A numbered text/chapter (1–48).
*/
export async function getLobChapter(
chapterNum: number,
): Promise<LobChapter | null> {
if (chapterNum < 1 || chapterNum > 48 || !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-preface" → intro segment
* "intro-acknowledgement" → intro segment
* "intro-the-artist" → intro segment
* "1" … "48" → chapter (text)
*/
export async function getLobContent(
slug: string,
): Promise<LobChapter | LobIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLobIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 48) {
return getLobChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → acknowledgement → the-artist → texts 1–48
*/
export async function getLobSidebarItems(): Promise<LobSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLobMeta(),
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: LobMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LobSidebarItem[] {
const items: LobSidebarItem[] = [];
// Intro segments (all come before chapters; LoB has no back matter)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Texts 1–48 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Text ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: LobMeta | null): LobSidebarItem[] {
const items: LobSidebarItem[] = [];
// Local intro segments (front matter only — LoB has no back matter)
const introSegs = getLocalIntroSegments();
for (const seg of introSegs) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Texts from meta or generate default 1-48
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Text ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(LOB_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<LobChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Text ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
export async function getLobSidebarItems() {
return getSidebarItems(CODE);
}
+92 -38
View File
@@ -1,13 +1,11 @@
/**
* Data access layer for Magazines (Back to Godhead, etc.).
*
* Uses dedicated Prisma models:
* - MagazineMeta (collection: magazine_meta)
* - MagazineIssue (collection: magazine_issues)
* Uses the BFF API instead of direct Prisma queries.
* Falls back to local JSON files when BFF is unavailable.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList } from "@/lib/bff";
import fs from "fs";
import path from "path";
@@ -57,6 +55,35 @@ export type MagazineSidebarItem = {
type: string;
};
// --- BFF response types ---
type BffMagazineMeta = {
id: string;
book: string;
data: {
title: string;
author: string;
year: number | null;
description: string | null;
totalIssues: number;
totalArticles: number;
flag: string | null;
chapters: MagazineChapterEntry[];
};
};
type BffMagazineIssue = {
book: string;
volume: number | null;
part: number;
title: string;
slug: string;
paragraphs: string[];
paragraphs_json?: string;
};
// --- HTML sanitisation ---
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
@@ -71,7 +98,7 @@ function sanitizeHtml(html: string): string {
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
@@ -88,6 +115,57 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
return result.map(sanitizeHtml);
}
// --- BFF → domain mappers ---
function mapBffMetaToMagazineMetaData(raw: BffMagazineMeta): MagazineMetaData {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalIssues: raw.data.totalIssues,
totalArticles: raw.data.totalArticles,
flag: raw.data.flag ?? null,
chapters: raw.data.chapters,
};
}
function mapBffMetaToSummary(raw: BffMagazineMeta): MagazineSummary {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalIssues: raw.data.totalIssues,
flag: raw.data.flag ?? null,
};
}
function mapBffIssue(raw: BffMagazineIssue): MagazineIssueData {
// Handle paragraphs_json fallback
let paragraphs = raw.paragraphs;
if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) {
try {
paragraphs = JSON.parse(raw.paragraphs_json);
} catch {
paragraphs = [];
}
}
return {
book: raw.book,
volume: raw.volume,
part: raw.part,
title: raw.title,
slug: raw.slug,
paragraphs: normalizeParagraphs(paragraphs),
};
}
// --- Local JSON fallback helpers ---
function findLocalDirForBook(bookCode: string): string | null {
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
const entries = fs.readdirSync(LOCAL_JSON_DIR);
@@ -152,22 +230,9 @@ function getLocalIssue(
export async function getAllMagazines(): Promise<MagazineSummary[]> {
try {
const results = await prisma.magazineMeta.findMany({
orderBy: { book: "asc" },
});
const results = await bffList<BffMagazineMeta>("/magazines");
if (results.length > 0) {
return results.map((row) => {
const data = row.data as unknown as MagazineMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalIssues: data.totalIssues,
flag: data.flag ?? null,
};
});
return results.map(mapBffMetaToSummary);
}
} catch {
/* fall through */
@@ -188,10 +253,8 @@ export async function getMagazineMeta(
bookCode: string,
): Promise<MagazineMetaData | null> {
try {
const row = await prisma.magazineMeta.findUnique({
where: { book: bookCode },
});
if (row) return row.data as unknown as MagazineMetaData;
const raw = await bff<BffMagazineMeta>(`/magazines/${bookCode}`);
if (raw) return mapBffMetaToMagazineMetaData(raw);
} catch {
/* fall through */
}
@@ -204,19 +267,10 @@ export async function getMagazineIssue(
): Promise<MagazineIssueData | null> {
if (!slug) return null;
try {
const doc = await prisma.magazineIssue.findUnique({
where: { book_slug: { book: bookCode, slug } },
});
if (doc) {
return {
book: doc.book,
volume: doc.volume,
part: doc.part,
title: doc.title,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
const raw = await bff<BffMagazineIssue>(
`/magazines/${bookCode}/issues/${slug}`,
);
if (raw) return mapBffIssue(raw);
} catch {
/* fall through */
}
+26 -249
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
*
* Reads from MongoDB via Prisma.
* MG is a simple prose book with 1 intro segment (front-pages) and 7 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "mg";
const BOOK = "mg";
// Re-export types with book-specific names for backwards compatibility
export type MgMeta = BookMeta;
export type MgChapterEntry = { number: number; title: string };
export type MgIntroSegment = IntroSegment;
export type MgChapter = Chapter;
export type MgSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MgMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: MgChapterEntry[];
};
export type MgChapterEntry = {
number: number;
title: string;
};
export type MgIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type MgChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type MgSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "7" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getMgMeta(): Promise<MgMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as MgMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getMgIntroSegment(
slug: string,
): Promise<MgIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–7).
*/
export async function getMgChapter(
chapterNum: number,
): Promise<MgChapter | null> {
if (chapterNum < 1 || chapterNum > 7 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "7" → chapter
*/
export async function getMgContent(
slug: string,
): Promise<MgChapter | MgIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getMgIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
return getMgChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–7
*/
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getMgMeta(),
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: MgSidebarItem[] = [];
// 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–7 (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;
return getSidebarItems(CODE);
}
+31 -416
View File
@@ -1,430 +1,45 @@
/**
* Data access layer for Message of Godhead (MOG).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* MOG 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/mog/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "mog";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type MogMeta = BookMeta;
export type MogChapterEntry = { number: number; title: string };
export type MogIntroSegment = IntroSegment;
export type MogChapter = Chapter;
export type MogSidebarItem = SidebarItem;
const BOOK = "mog";
const MOG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mog");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MogMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: MogChapterEntry[];
};
export type MogChapterEntry = {
number: number;
title: string;
};
export type MogIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type MogChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type MogSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", "3" */
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;
export async function getMogMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getMogIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getMogIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getMogChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getMogContent(slug: string) {
return getContent(CODE, slug);
}
}
function getLocalIntroSegments(): MogIntroSegment[] {
const introDir = path.join(MOG_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: MogIntroSegment[] = [];
const files = ["front-pages.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<MogIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): MogIntroSegment | null {
const filePath = path.join(MOG_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<MogIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): MogChapter | null {
const filePath = path.join(
MOG_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<MogChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getMogMeta(): Promise<MogMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as MogMeta;
// Fallback to local JSON
const introPath = path.join(MOG_JSON_DIR, "intro.json");
return readLocalJson<MogMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getMogIntroSegments(): Promise<MogIntroSegment[]> {
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-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getMogIntroSegment(
slug: string,
): Promise<MogIntroSegment | null> {
// 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–3).
*/
export async function getMogChapter(
chapterNum: number,
): Promise<MogChapter | null> {
if (chapterNum < 1 || chapterNum > 3 || !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
* "1" … "3" → chapter
*/
export async function getMogContent(
slug: string,
): Promise<MogChapter | MogIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getMogIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 3) {
return getMogChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–3
*/
export async function getMogSidebarItems(): Promise<MogSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getMogMeta(),
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: MogMeta,
allDocs: {
slug: string;
title: string;
type: string;
chapter: number | null;
order: number | null;
}[],
): MogSidebarItem[] {
const items: MogSidebarItem[] = [];
// Intro segments (front-pages)
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–3 (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: MogMeta | null): MogSidebarItem[] {
const items: MogSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
for (const seg of introSegs) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// 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(MOG_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<MogChapter>(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;
export async function getMogSidebarItems() {
return getSidebarItems(CODE);
}
+34 -317
View File
@@ -1,332 +1,49 @@
/**
* Data access layer for The Nectar of Devotion (NOD).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* NoD 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)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "nod";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type NodMeta = BookMeta;
export type NodChapterEntry = { number: number; title: string };
export type NodIntroSegment = IntroSegment;
export type NodChapter = Chapter;
export type NodSidebarItem = SidebarItem;
const BOOK = "nod";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NodMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: NodPart[];
chapters: NodChapterEntry[];
};
export type NodPart = {
number: number;
title: string;
chapters: number[];
};
export type NodChapterEntry = {
number: number;
title: string;
};
export type NodIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type NodChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type NodSidebarItem = {
/** URL-safe slug: "intro-front-flap", "1", "51", "concluding-words" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" | "conclusion" */
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;
export async function getNodMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getNodIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getNodIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getNodChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, parts, chapter list, etc.)
*/
export async function getNodMeta(): Promise<NodMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as NodMeta;
// NodConcludingWords is just a chapter with slug "concluding-words"
export async function getNodConcludingWords() {
return getChapter(CODE, "concluding-words");
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getNodIntroSegments(): Promise<NodIntroSegment[]> {
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),
}));
export async function getNodContent(slug: string) {
return getContent(CODE, slug);
}
/**
* A single intro segment by slug.
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
*/
export async function getNodIntroSegment(
slug: string,
): Promise<NodIntroSegment | 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–51).
*/
export async function getNodChapter(
chapterNum: number,
): Promise<NodChapter | null> {
if (chapterNum < 1 || chapterNum > 51 || !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),
};
}
/**
* The concluding words section.
*/
export async function getNodConcludingWords(): Promise<NodChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: "concluding-words",
},
},
});
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" … "51" → chapter
* "concluding-words" → concluding section
*/
export async function getNodContent(
slug: string,
): Promise<NodChapter | NodIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getNodIntroSegment(slug);
}
if (slug === "concluding-words") {
return getNodConcludingWords();
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 51) {
return getNodChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: intro segments → chapters 1–51 → concluding words
*/
export async function getNodSidebarItems(): Promise<NodSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getNodMeta(),
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: NodSidebarItem[] = [];
// 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–51 (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,
});
}
// Concluding words
const cw = allDocs.find((d) => d.type === "conclusion");
if (cw) {
items.push({
slug: cw.slug,
label: cw.title,
type: "conclusion",
chapter: null,
});
}
return items;
export async function getNodSidebarItems() {
return getSidebarItems(CODE);
}
+45 -278
View File
@@ -1,322 +1,89 @@
/**
* 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).
* Delegates to the generic book data layer (BFF API) for prose intro segments,
* and to lib/api.ts for verse (text) lookups.
*
* 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"
* NoI has 11 texts (verses) plus intro segments.
* Verses are stored with: book "noi", division null, chapter = text number, verse = 1.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { getSingleVerse, type VerseRecord } from "@/lib/api";
const CODE = "noi";
// ---------------------------------------------------------------------------
// Constants
// Re-export types with book-specific names for backwards compatibility
// ---------------------------------------------------------------------------
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);
}
export type NoiMeta = BookMeta;
export type NoiIntroSegment = IntroSegment;
export type NoiChapter = Chapter;
export type NoiSidebarItem = SidebarItem;
export type NoiVerse = VerseRecord;
// ---------------------------------------------------------------------------
// 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;
export async function getNoiMeta() {
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// Intro segments
// ---------------------------------------------------------------------------
/**
* 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),
}));
export async function getNoiIntroSegments() {
return getIntroSegments(CODE);
}
/**
* 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),
};
export async function getNoiIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Verses (texts 1-11, from verses collection)
// Verses (texts 1-11, from verses collection via BFF)
// ---------------------------------------------------------------------------
/**
* 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),
};
const num = parseInt(slug, 10);
if (isNaN(num)) return null;
return getSingleVerse(CODE, null, num, 1);
}
// ---------------------------------------------------------------------------
// 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);
): Promise<NoiVerse | NoiIntroSegment | NoiChapter | null> {
// Try as verse first (numeric slug)
if (/^\d+$/.test(slug)) {
const verse = await getNoiVerse(slug);
if (verse) return verse;
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getNoiVerse(String(num));
}
return null;
// Fall back to prose content (intro segments, etc.)
return getContent(CODE, slug);
}
// ---------------------------------------------------------------------------
// 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;
export async function getNoiSidebarItems() {
return getSidebarItems(CODE);
}
+27 -250
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for On Chanting the Hare Kṛṣṇa Mantra (OCHKM).
* Data access layer for On Chanting Hare Kṛṣṇa (OCHKM).
*
* Reads from MongoDB via Prisma.
* OCHKM is a prose book with 1 intro segment (front-pages) and 4 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "ochkm";
const BOOK = "ochkm";
// Re-export types with book-specific names for backwards compatibility
export type OchkmMeta = BookMeta;
export type OchkmChapterEntry = { number: number; title: string };
export type OchkmIntroSegment = IntroSegment;
export type OchkmChapter = Chapter;
export type OchkmSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OchkmMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: OchkmChapterEntry[];
};
export type OchkmChapterEntry = {
number: number;
title: string;
};
export type OchkmIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type OchkmChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type OchkmSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "4" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getOchkmMeta(): Promise<OchkmMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as OchkmMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getOchkmIntroSegment(
slug: string,
): Promise<OchkmIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getOchkmChapter(
chapterNum: number,
): Promise<OchkmChapter | null> {
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "4" → chapter
*/
export async function getOchkmContent(
slug: string,
): Promise<OchkmChapter | OchkmIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getOchkmIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getOchkmChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–4
*/
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getOchkmMeta(),
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: OchkmSidebarItem[] = [];
// 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–4 (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;
return getSidebarItems(CODE);
}
+86 -54
View File
@@ -1,18 +1,14 @@
/**
* Data access layer for Other Documents.
*
* Uses dedicated Prisma models:
* - OtherDocumentMeta (collection: other_document_meta)
* - OtherDocumentChapter (collection: other_document_chapters)
*
* Calls the BFF API for all data access (no direct Prisma usage).
* Falls back to reading local JSON files from
* lib/jsons/other-documents/<slug>/ when the database is unavailable.
* lib/jsons/other-documents/<slug>/ when the BFF is unavailable.
*
* Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula").
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList } from "@/lib/bff";
import fs from "fs";
import path from "path";
@@ -72,6 +68,33 @@ export type OtherDocumentSidebarItem = {
chapter: number;
};
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffDocumentMeta = {
id: string;
book: string;
data: {
title: string;
author: string;
year?: number | null;
description?: string | null;
totalChapters: number;
chapters: { number: number; title: string }[];
};
};
type BffChapter = {
id: string;
book: string;
chapter: number;
title: string;
type: string;
slug: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers)
// ---------------------------------------------------------------------------
@@ -90,23 +113,52 @@ function sanitizeHtml(html: string): string {
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
// ---------------------------------------------------------------------------
// Mappers (BFF response → frontend types)
// ---------------------------------------------------------------------------
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 = [];
function mapMetaData(raw: BffDocumentMeta): OtherDocumentMetaData {
const data =
raw.data && typeof raw.data === "object"
? raw.data
: ({} as BffDocumentMeta["data"]);
return {
book: raw.book,
title: data.title ?? "",
author: data.author ?? "",
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters ?? 0,
chapters: (data.chapters ?? []).map((ch) => ({
number: ch.number,
title: ch.title,
})),
};
}
return result.map(sanitizeHtml);
function mapSummary(raw: BffDocumentMeta): OtherDocumentSummary {
const data =
raw.data && typeof raw.data === "object"
? raw.data
: ({} as BffDocumentMeta["data"]);
return {
book: raw.book,
title: data.title ?? "",
author: data.author ?? "",
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters ?? 0,
};
}
function mapChapter(raw: BffChapter): OtherDocumentChapterData {
return {
book: raw.book,
chapter: raw.chapter,
title: raw.title,
type: raw.type,
paragraphs: (raw.paragraphs ?? []).map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
@@ -195,22 +247,12 @@ function getLocalChapter(
*/
export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> {
try {
const results = await prisma.otherDocumentMeta.findMany({
orderBy: { book: "asc" },
const results = await bffList<BffDocumentMeta>("/other-documents", {
tags: ["other-documents"],
});
if (results.length > 0) {
return results.map((row) => {
const data = row.data as unknown as OtherDocumentMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters,
};
});
return results.map(mapSummary);
}
} catch {
// fall through to local JSON
@@ -234,12 +276,12 @@ export async function getOtherDocumentMeta(
bookCode: string,
): Promise<OtherDocumentMetaData | null> {
try {
const row = await prisma.otherDocumentMeta.findUnique({
where: { book: bookCode },
const raw = await bff<BffDocumentMeta>(`/other-documents/${bookCode}`, {
tags: [`other-doc-${bookCode}`],
});
if (row) {
return row.data as unknown as OtherDocumentMetaData;
if (raw) {
return mapMetaData(raw);
}
} catch {
// fall through to local JSON
@@ -258,23 +300,13 @@ export async function getOtherDocumentChapter(
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
try {
const doc = await prisma.otherDocumentChapter.findUnique({
where: {
book_slug: {
book: bookCode,
slug: String(chapterNum),
},
},
});
const raw = await bff<BffChapter>(
`/other-documents/${bookCode}/chapters/${chapterNum}`,
{ tags: [`other-doc-${bookCode}`] },
);
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
if (raw) {
return mapChapter(raw);
}
} catch {
// fall through to local JSON
+26 -249
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for On the Way to Kṛṣṇa (OWK).
*
* Reads from MongoDB via Prisma.
* OWK is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "owk";
const BOOK = "owk";
// Re-export types with book-specific names for backwards compatibility
export type OwkMeta = BookMeta;
export type OwkChapterEntry = { number: number; title: string };
export type OwkIntroSegment = IntroSegment;
export type OwkChapter = Chapter;
export type OwkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OwkMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: OwkChapterEntry[];
};
export type OwkChapterEntry = {
number: number;
title: string;
};
export type OwkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type OwkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type OwkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getOwkMeta(): Promise<OwkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as OwkMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getOwkIntroSegment(
slug: string,
): Promise<OwkIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getOwkChapter(
chapterNum: number,
): Promise<OwkChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getOwkContent(
slug: string,
): Promise<OwkChapter | OwkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getOwkIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getOwkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getOwkMeta(),
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: OwkSidebarItem[] = [];
// 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–5 (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;
return getSidebarItems(CODE);
}
+22 -299
View File
@@ -1,306 +1,29 @@
/**
* 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 {
getBookMeta,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
const CODE = "pk";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export type PkMeta = BookMeta;
export type PkChapterEntry = { number: number; title: string };
export type PkChapter = Chapter;
export type PkSidebarItem = SidebarItem;
/** 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;
export async function getPkMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getPkChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getPkContent(slug: string) {
return getContent(CODE, slug);
}
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;
export async function getPkSidebarItems() {
return getSidebarItems(CODE);
}
+26 -249
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for The Perfection of Yoga (POY).
*
* Reads from MongoDB via Prisma.
* POY is a prose book with 1 intro segment (front-pages) and 11 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "poy";
const BOOK = "poy";
// Re-export types with book-specific names for backwards compatibility
export type PoyMeta = BookMeta;
export type PoyChapterEntry = { number: number; title: string };
export type PoyIntroSegment = IntroSegment;
export type PoyChapter = Chapter;
export type PoySidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PoyMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: PoyChapterEntry[];
};
export type PoyChapterEntry = {
number: number;
title: string;
};
export type PoyIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type PoyChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PoySidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "11" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getPoyMeta(): Promise<PoyMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as PoyMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getPoyIntroSegment(
slug: string,
): Promise<PoyIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–11).
*/
export async function getPoyChapter(
chapterNum: number,
): Promise<PoyChapter | null> {
if (chapterNum < 1 || chapterNum > 11 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "11" → chapter
*/
export async function getPoyContent(
slug: string,
): Promise<PoyChapter | PoyIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getPoyIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getPoyChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–11
*/
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPoyMeta(),
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: PoySidebarItem[] = [];
// 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–11 (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;
return getSidebarItems(CODE);
}
+35 -322
View File
@@ -1,336 +1,49 @@
/**
* Data access layer for Perfect Questions Perfect Answers (PQPA).
* Data access layer for Perfect Questions, Perfect Answers (PQPA).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* PQPA is a prose/dialogue book with 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)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "pqpa";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type PqpaMeta = BookMeta;
export type PqpaChapterEntry = { number: number; title: string };
export type PqpaIntroSegment = IntroSegment;
export type PqpaChapter = Chapter;
export type PqpaSidebarItem = SidebarItem;
const BOOK = "pqpa";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PqpaMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: PqpaChapterEntry[];
};
export type PqpaChapterEntry = {
number: number;
title: string;
};
export type PqpaIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type PqpaChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PqpaSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "9", "concluding-words" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" | "conclusion" */
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;
export async function getPqpaMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getPqpaIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getPqpaIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getPqpaChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPqpaMeta(): Promise<PqpaMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as PqpaMeta;
// PqpaConcludingWords is just a chapter with slug "concluding-words"
export async function getPqpaConcludingWords() {
return getChapter(CODE, "concluding-words");
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getPqpaIntroSegments(): Promise<PqpaIntroSegment[]> {
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),
}));
export async function getPqpaContent(slug: string) {
return getContent(CODE, slug);
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getPqpaIntroSegment(
slug: string,
): Promise<PqpaIntroSegment | 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–9).
*/
export async function getPqpaChapter(
chapterNum: number,
): Promise<PqpaChapter | null> {
if (chapterNum < 1 || chapterNum > 9 || !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),
};
}
/**
* The concluding words section.
*/
export async function getPqpaConcludingWords(): Promise<PqpaChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: "concluding-words",
},
},
});
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-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "9" → chapter
* "concluding-words" → concluding section
*/
export async function getPqpaContent(
slug: string,
): Promise<PqpaChapter | PqpaIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getPqpaIntroSegment(slug);
}
if (slug === "concluding-words") {
return getPqpaConcludingWords();
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 9) {
return getPqpaChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–9 → concluding-words → about-the-author
*/
export async function getPqpaSidebarItems(): Promise<PqpaSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPqpaMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
if (!meta) return [];
const items: PqpaSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–9 (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,
});
}
// Concluding words
const cw = allDocs.find((d) => d.type === "conclusion");
if (cw) {
items.push({
slug: cw.slug,
label: cw.title,
type: "conclusion",
chapter: null,
});
}
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
export async function getPqpaSidebarItems() {
return getSidebarItems(CODE);
}
+26 -249
View File
@@ -1,280 +1,57 @@
/**
* Data access layer for Rāja-vidyā: The King of Knowledge (RV).
*
* Reads from MongoDB via Prisma.
* RV is a simple prose book with 1 intro segment (front-pages) and 8 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "rv";
const BOOK = "rv";
// Re-export types with book-specific names for backwards compatibility
export type RvMeta = BookMeta;
export type RvChapterEntry = { number: number; title: string };
export type RvIntroSegment = IntroSegment;
export type RvChapter = Chapter;
export type RvSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type RvMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: RvChapterEntry[];
};
export type RvChapterEntry = {
number: number;
title: string;
};
export type RvIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type RvChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type RvSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "8" */
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
// ---------------------------------------------------------------------------
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, chapter list, etc.)
*/
export async function getRvMeta(): Promise<RvMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as RvMeta;
return getBookMeta(CODE);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getRvIntroSegments(): Promise<RvIntroSegment[]> {
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),
}));
return getIntroSegments(CODE);
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getRvIntroSegment(
slug: string,
): Promise<RvIntroSegment | 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),
};
return getIntroSegment(CODE, slug);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–8).
*/
export async function getRvChapter(
chapterNum: number,
): Promise<RvChapter | null> {
if (chapterNum < 1 || chapterNum > 8 || !Number.isInteger(chapterNum)) {
return null;
return getChapter(CODE, String(chapterNum));
}
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-pages" → intro segment
* "1" … "8" → chapter
*/
export async function getRvContent(
slug: string,
): Promise<RvChapter | RvIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getRvIntroSegment(slug);
return getContent(CODE, slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
return getRvChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–8
*/
export async function getRvSidebarItems(): Promise<RvSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getRvMeta(),
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: RvSidebarItem[] = [];
// 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–8 (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;
return getSidebarItems(CODE);
}
+33 -322
View File
@@ -1,336 +1,47 @@
/**
* Data access layer for The Science of Self-Realization (SSR).
*
* Reads from MongoDB via Prisma.
* SSR is a large prose book with 6 intro segments (front-pages, dedication,
* foreword, introduction, appreciations, about-the-author) and 8 parent
* chapters, each containing multiple sub-chapters (e.g., "1-1", "1-2", "2-1").
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
getNavigableItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "ssr";
const BOOK = "ssr";
export type SsrMeta = BookMeta;
export type SsrSubChapterEntry = { slug: string; title: string };
export type SsrIntroSegment = IntroSegment;
export type SsrChapter = Chapter;
export type SsrSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SsrMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: SsrParentChapter[];
};
export type SsrParentChapter = {
number: number;
title: string;
subChapters: SsrSubChapterEntry[];
};
export type SsrSubChapterEntry = {
slug: string; // "1-1", "1-2", etc.
title: string;
};
export type SsrIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type SsrChapter = {
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 SsrSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", "intro-about-the-author" */
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(/<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;
export async function getSsrMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getSsrIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getSsrIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getSsrChapter(slug: string) {
return getChapter(CODE, slug);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
*/
export async function getSsrMeta(): Promise<SsrMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as SsrMeta;
export async function getSsrContent(slug: string) {
return getContent(CODE, slug);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getSsrIntroSegments(): Promise<SsrIntroSegment[]> {
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 getSsrIntroSegment(
slug: string,
): Promise<SsrIntroSegment | 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 (sub-chapters like "1-1", "2-3", etc.)
// ---------------------------------------------------------------------------
/**
* A sub-chapter by its slug (e.g. "1-1", "2-3").
*/
export async function getSsrChapter(slug: string): Promise<SsrChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug,
},
},
});
if (!doc) return null;
// 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 getSsrMeta();
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),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-dedication" → intro segment
* "intro-about-the-author" → intro segment
* "1-1", "2-3", etc. → sub-chapter
*/
export async function getSsrContent(
slug: string,
): Promise<SsrChapter | SsrIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getSsrIntroSegment(slug);
}
// Sub-chapter slug like "1-1", "2-3"
return getSsrChapter(slug);
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list with hierarchical structure.
*
* Order: front-pages → dedication → foreword → introduction → appreciations
* → parent-1 (label) → 1-1, 1-2, ... → parent-2 (label) → 2-1, 2-2, ...
* → about-the-author
*
* "parent" type items are non-clickable headings. Only "intro-segment" and
* "sub-chapter" items are navigable pages.
*/
export async function getSsrSidebarItems(): Promise<SsrSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getSsrMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
if (!meta) return [];
const items: SsrSidebarItem[] = [];
// 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: `${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, e.g., about-the-author)
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;
}
/**
* Get the flat list of navigable sidebar items (excludes "parent" headings).
* Used for prev/next navigation.
*/
export function getNavigableItems(items: SsrSidebarItem[]): SsrSidebarItem[] {
return items.filter((item) => item.type !== "parent");
export async function getSsrSidebarItems() {
return getSidebarItems(CODE);
}
export { getNavigableItems };
+34 -487
View File
@@ -1,501 +1,48 @@
/**
* 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)
* Delegates to the generic book data layer which calls the BFF API.
* SVA is a hierarchical book with parent chapters and sub-chapters (songs).
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
getNavigableItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "sva";
const BOOK = "sva";
export type SvaMeta = BookMeta;
export type SvaSubChapterEntry = { slug: string; title: string };
export type SvaIntroSegment = IntroSegment;
export type SvaChapter = Chapter;
export type SvaSidebarItem = SidebarItem;
/** 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(/<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;
export async function getSvaMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getSvaIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getSvaIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getSvaChapter(slug: string) {
return getChapter(CODE, slug);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
export async function getSvaContent(slug: string) {
return getContent(CODE, slug);
}
export async function getSvaSidebarItems() {
return getSidebarItems(CODE);
}
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<SvaIntroSegment>(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<SvaChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
*/
export async function getSvaMeta(): Promise<SvaMeta | null> {
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<SvaMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getSvaIntroSegments(): Promise<SvaIntroSegment[]> {
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<SvaIntroSegment | null> {
// 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<SvaIntroSegment>(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<SvaChapter | null> {
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<SvaChapter | SvaIntroSegment | null> {
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<SvaSidebarItem[]> {
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<SvaMeta>(
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");
}
export { getNavigableItems };
+42 -154
View File
@@ -1,164 +1,52 @@
/**
* Data access layer for Two Essays (TE).
*
* Reads from MongoDB via Prisma.
* TE is a collection of 2 standalone essays using the `essays` collection
* (not `prose_chapters`). Essays are grouped by `group: "two-essays"`.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - essays (via Prisma Essay model)
*/
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
const CODE = "te";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export type TeMeta = BookMeta;
export type TeEssayEntry = { slug: string; title: string; order: number };
export type TeIntroSegment = IntroSegment;
export type TeEssay = Chapter;
export type TeChapter = Chapter;
export type TeSidebarItem = SidebarItem;
const GROUP = "two-essays";
const BOOK = "te";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TeMeta = {
group: string;
title: string;
author: string;
year: number;
description: string;
essays: TeEssayEntry[];
};
export type TeEssayEntry = {
slug: string;
title: string;
order: number;
};
export type TeEssay = {
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
order: number;
paragraphs: string[];
};
export type TeSidebarItem = {
slug: string;
label: string;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/ej.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;
export async function getTeMeta() {
return getBookMeta(CODE);
}
export async function getTeIntroSegments() {
return getIntroSegments(CODE);
}
export async function getTeIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
export async function getTeChapter(slug: string) {
return getChapter(CODE, slug);
}
export async function getTeContent(slug: string) {
return getContent(CODE, slug);
}
export async function getTeSidebarItems() {
return getSidebarItems(CODE);
}
/** 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, essay list, etc.)
*/
export async function getTeMeta(): Promise<TeMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as TeMeta;
}
// ---------------------------------------------------------------------------
// Essays
// ---------------------------------------------------------------------------
/**
* All essays in the "two-essays" group, ordered by `order`.
*/
export async function getTeEssays(): Promise<TeEssay[]> {
const results = await prisma.essay.findMany({
where: { group: GROUP },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
author: doc.author,
year: doc.year,
group: doc.group,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
const items = await getIntroSegments(CODE);
return items as unknown as TeEssay[];
}
/**
* A single essay by slug.
*/
export async function getTeEssay(slug: string): Promise<TeEssay | null> {
const doc = await prisma.essay.findUnique({ where: { slug } });
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
author: doc.author,
year: doc.year,
group: doc.group,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the sidebar navigation list from meta.
*/
export async function getTeSidebarItems(): Promise<TeSidebarItem[]> {
const meta = await getTeMeta();
if (!meta) return [];
return meta.essays.map((e) => ({
slug: e.slug,
label: e.title,
}));
const chapter = await getChapter(CODE, slug);
if (chapter) return chapter;
const intro = await getIntroSegment(CODE, slug);
return intro as unknown as TeEssay | null;
}
+31 -278
View File
@@ -1,292 +1,45 @@
/**
* 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)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "tlc";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type TlcMeta = BookMeta;
export type TlcChapterEntry = { number: number; title: string };
export type TlcIntroSegment = IntroSegment;
export type TlcChapter = Chapter;
export type TlcSidebarItem = SidebarItem;
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;
export async function getTlcMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getTlcIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getTlcIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getTlcChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getTlcContent(slug: string) {
return getContent(CODE, slug);
}
// ---------------------------------------------------------------------------
// 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;
export async function getTlcSidebarItems() {
return getSidebarItems(CODE);
}
+31 -439
View File
@@ -1,453 +1,45 @@
/**
* 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)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "tlk";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type TlkMeta = BookMeta;
export type TlkChapterEntry = { number: number; title: string };
export type TlkIntroSegment = IntroSegment;
export type TlkChapter = Chapter;
export type TlkSidebarItem = SidebarItem;
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(/<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;
export async function getTlkMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getTlkIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getTlkIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getTlkChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getTlkContent(slug: string) {
return getContent(CODE, slug);
}
}
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<TlkIntroSegment>(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<TlkIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): TlkChapter | null {
const filePath = path.join(
TLK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<TlkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getTlkMeta(): Promise<TlkMeta | null> {
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<TlkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTlkIntroSegments(): Promise<TlkIntroSegment[]> {
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<TlkIntroSegment | null> {
// 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<TlkChapter | null> {
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<TlkChapter | TlkIntroSegment | null> {
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<TlkSidebarItem[]> {
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<TlkChapter>(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;
export async function getTlkSidebarItems() {
return getSidebarItems(CODE);
}
+31 -435
View File
@@ -1,449 +1,45 @@
/**
* Data access layer for Teachings of Queen Kuntī (TQK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* TQK 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/tqk/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "tqk";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
export type TqkMeta = BookMeta;
export type TqkChapterEntry = { number: number; title: string };
export type TqkIntroSegment = IntroSegment;
export type TqkChapter = Chapter;
export type TqkSidebarItem = SidebarItem;
const BOOK = "tqk";
const TQK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "tqk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TqkMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: TqkChapterEntry[];
};
export type TqkChapterEntry = {
number: number;
title: string;
};
export type TqkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TqkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TqkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "26", "intro-back-flap" */
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;
export async function getTqkMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getTqkIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getTqkIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getTqkChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// 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;
export async function getTqkContent(slug: string) {
return getContent(CODE, slug);
}
}
function getLocalIntroSegments(): TqkIntroSegment[] {
const introDir = path.join(TQK_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: TqkIntroSegment[] = [];
const files = [
"front-pages.json",
"introduction.json",
"back-flap.json",
];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<TqkIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): TqkIntroSegment | null {
const filePath = path.join(TQK_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<TqkIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): TqkChapter | null {
const filePath = path.join(
TQK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<TqkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getTqkMeta(): Promise<TqkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as TqkMeta;
// Fallback to local JSON
const introPath = path.join(TQK_JSON_DIR, "intro.json");
return readLocalJson<TqkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTqkIntroSegments(): Promise<TqkIntroSegment[]> {
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-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getTqkIntroSegment(
slug: string,
): Promise<TqkIntroSegment | null> {
// 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–26).
*/
export async function getTqkChapter(
chapterNum: number,
): Promise<TqkChapter | null> {
if (chapterNum < 1 || chapterNum > 26 || !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-introduction" → intro segment
* "intro-back-flap" → intro segment
* "1" … "26" → chapter
*/
export async function getTqkContent(
slug: string,
): Promise<TqkChapter | TqkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTqkIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 26) {
return getTqkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–26 → back-flap
*/
export async function getTqkSidebarItems(): Promise<TqkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTqkMeta(),
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: TqkMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): TqkSidebarItem[] {
const items: TqkSidebarItem[] = [];
// 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–26 (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. 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: TqkMeta | null): TqkSidebarItem[] {
const items: TqkSidebarItem[] = [];
// 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-26
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(TQK_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<TqkChapter>(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 (back-flap)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
export async function getTqkSidebarItems() {
return getSidebarItems(CODE);
}
+142 -134
View File
@@ -1,16 +1,22 @@
/**
* Data access layer for Transcriptions.
*
* Uses Prisma to query the `transcriptions` collection in MongoDB.
* Replaces the previous local-JSON-file approach so that data is always
* served from the database (seeded via seed-transcriptions.ts).
* Calls the Go BFF API instead of Prisma directly.
* All responses are unwrapped from the BFF envelope by lib/bff.ts.
*
* Some documents store their content as a JSON string in `contentJson`
* (a BSON wire-protocol workaround for very large nested arrays).
* The `getTranscription()` helper transparently parses that fallback.
* BFF endpoints used:
* GET /api/v1/transcriptions → { count }
* GET /api/v1/transcriptions/years → [{ _id, count }]
* GET /api/v1/transcriptions/places → [{ _id, count }]
* GET /api/v1/transcriptions/types → { lectures, conversations }
* GET /api/v1/transcriptions/year/{year} → [TranscriptionSummary]
* GET /api/v1/transcriptions/place/{place} → [TranscriptionSummary]
* GET /api/v1/transcriptions/type/{type} → [TranscriptionSummary]
* GET /api/v1/transcriptions/{slug} → TranscriptionFull
* GET /api/v1/transcriptions/adjacent/{slug}?year=N → { prev, next }
*/
import { prisma } from "@/lib/db";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
@@ -54,25 +60,37 @@ export type PlaceGroup = {
};
// ---------------------------------------------------------------------------
// Prisma select objects (keep queries lightweight)
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
/** Fields needed for summary views (no content). */
const SUMMARY_SELECT = {
slug: true,
name: true,
type: true,
date: true,
year: true,
month: true,
day: true,
place: true,
context: true,
reference: true,
durationMinutes: true,
participants: true,
audioUrl: true,
} as const;
type BffSummary = {
id?: string;
slug: string;
name: string;
type: string;
date?: string | null;
year?: number | null;
month?: number | null;
day?: number | null;
place?: string | null;
context?: string | null;
reference?: string | null;
duration_minutes?: number | null;
audio_url?: string | null;
participants?: string[];
};
type BffFull = BffSummary & {
content?: BffContentBlock[];
content_json?: string;
};
type BffContentBlock = {
text: string;
format: string;
alignment: string;
speaker?: string | null;
};
// ---------------------------------------------------------------------------
// Sanitize
@@ -90,49 +108,88 @@ function sanitizeHtml(html: string): string {
return clean;
}
// ---------------------------------------------------------------------------
// Mappers (snake_case BFF → camelCase frontend)
// ---------------------------------------------------------------------------
function mapSummary(s: BffSummary): TranscriptionSummary {
return {
slug: s.slug,
name: s.name,
type: s.type,
date: s.date ?? null,
year: s.year ?? null,
month: s.month ?? null,
day: s.day ?? null,
place: s.place ?? null,
context: s.context ?? null,
reference: s.reference ?? null,
durationMinutes: s.duration_minutes ?? null,
participants: s.participants ?? [],
audioUrl: s.audio_url ?? null,
};
}
function mapContentBlock(b: BffContentBlock): ContentBlock {
return {
text: sanitizeHtml(b.text),
format: b.format,
alignment: b.alignment,
speaker: b.speaker ?? null,
};
}
function mapFull(t: BffFull): TranscriptionFull {
let content: ContentBlock[] = [];
if (t.content && t.content.length > 0) {
content = t.content.map(mapContentBlock);
} else if (t.content_json && t.content_json.length > 2) {
// contentJson fallback (BSON workaround for very large nested arrays)
try {
const parsed = JSON.parse(t.content_json) as BffContentBlock[];
content = parsed.map(mapContentBlock);
} catch {
// Ignore parse errors — return empty content
}
}
return {
...mapSummary(t),
content,
};
}
// ---------------------------------------------------------------------------
// Index / overview queries
// ---------------------------------------------------------------------------
export async function getTranscriptionYears(): Promise<YearGroup[]> {
const transcriptions = await prisma.transcription.findMany({
where: { year: { not: null }, isPublished: true },
select: { year: true },
});
const counts: Record<number, number> = {};
for (const t of transcriptions) {
if (t.year) {
counts[t.year] = (counts[t.year] || 0) + 1;
}
}
return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count }))
const raw = await bffList<{ _id: number; count: number }>(
"/transcriptions/years",
{ tags: ["transcriptions"] },
);
return raw
.map((item) => ({ year: item._id, count: item.count }))
.filter((g) => g.year >= 1960 && g.year <= 1980)
.sort((a, b) => a.year - b.year);
}
export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
const transcriptions = await prisma.transcription.findMany({
where: { place: { not: null }, isPublished: true },
select: { place: true },
});
const counts: Record<string, number> = {};
for (const t of transcriptions) {
if (t.place) {
counts[t.place] = (counts[t.place] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
const raw = await bffList<{ _id: string; count: number }>(
"/transcriptions/places",
{ tags: ["transcriptions"] },
);
return raw
.map((item) => ({ name: item._id, count: item.count }))
.sort((a, b) => b.count - a.count);
}
export async function getTotalTranscriptionCount(): Promise<number> {
return prisma.transcription.count({ where: { isPublished: true } });
const res = await bff<{ count: number }>("/transcriptions", {
tags: ["transcriptions"],
});
return res?.count ?? 0;
}
/**
@@ -143,21 +200,18 @@ export async function getTranscriptionIndex(): Promise<{
totalTranscriptions: number;
typeDistribution: Record<string, number>;
}> {
const [total, lectures, conversations] = await Promise.all([
prisma.transcription.count({ where: { isPublished: true } }),
prisma.transcription.count({
where: { isPublished: true, type: "lecture" },
}),
prisma.transcription.count({
where: { isPublished: true, type: "conversation" },
const [countRes, typesRes] = await Promise.all([
bff<{ count: number }>("/transcriptions", { tags: ["transcriptions"] }),
bff<{ lectures: number; conversations: number }>("/transcriptions/types", {
tags: ["transcriptions"],
}),
]);
return {
totalTranscriptions: total,
totalTranscriptions: countRes?.count ?? 0,
typeDistribution: {
lecture: lectures,
conversation: conversations,
lecture: typesRes?.lectures ?? 0,
conversation: typesRes?.conversations ?? 0,
},
};
}
@@ -169,34 +223,29 @@ export async function getTranscriptionIndex(): Promise<{
export async function getTranscriptionsByYear(
year: number,
): Promise<TranscriptionSummary[]> {
const transcriptions = await prisma.transcription.findMany({
where: { year, isPublished: true },
orderBy: [{ month: "asc" }, { day: "asc" }],
select: SUMMARY_SELECT,
const raw = await bffList<BffSummary>(`/transcriptions/year/${year}`, {
tags: ["transcriptions"],
});
return transcriptions;
return raw.map(mapSummary);
}
export async function getTranscriptionsByPlace(
place: string,
): Promise<TranscriptionSummary[]> {
const transcriptions = await prisma.transcription.findMany({
where: { place, isPublished: true },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: SUMMARY_SELECT,
const encoded = encodeURIComponent(place);
const raw = await bffList<BffSummary>(`/transcriptions/place/${encoded}`, {
tags: ["transcriptions"],
});
return transcriptions;
return raw.map(mapSummary);
}
export async function getTranscriptionsByType(
type: string,
): Promise<TranscriptionSummary[]> {
const transcriptions = await prisma.transcription.findMany({
where: { type, isPublished: true },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: SUMMARY_SELECT,
const raw = await bffList<BffSummary>(`/transcriptions/type/${type}`, {
tags: ["transcriptions"],
});
return transcriptions;
return raw.map(mapSummary);
}
// ---------------------------------------------------------------------------
@@ -206,54 +255,11 @@ export async function getTranscriptionsByType(
export async function getTranscription(
slug: string,
): Promise<TranscriptionFull | null> {
const t = await prisma.transcription.findUnique({ where: { slug } });
if (!t || !t.isPublished) return null;
// Content may be stored as embedded array or as a JSON string fallback
let content: ContentBlock[] = [];
if (t.content && t.content.length > 0) {
content = t.content.map((block) => ({
text: sanitizeHtml(block.text),
format: block.format,
alignment: block.alignment,
speaker: block.speaker ?? null,
}));
} else {
// Check for contentJson fallback (BSON workaround for large documents)
const raw = t as unknown as Record<string, unknown>;
const contentJson = raw.contentJson;
if (typeof contentJson === "string" && contentJson.length > 2) {
try {
const parsed = JSON.parse(contentJson) as ContentBlock[];
content = parsed.map((block) => ({
text: sanitizeHtml(block.text),
format: block.format,
alignment: block.alignment,
speaker: block.speaker ?? null,
}));
} catch {
// Ignore parse errors — return empty content
}
}
}
return {
slug: t.slug,
name: t.name,
type: t.type,
date: t.date,
year: t.year,
month: t.month,
day: t.day,
place: t.place,
context: t.context,
reference: t.reference,
durationMinutes: t.durationMinutes,
participants: t.participants,
audioUrl: t.audioUrl,
content,
};
const raw = await bff<BffFull>(`/transcriptions/${slug}`, {
tags: ["transcriptions"],
});
if (!raw) return null;
return mapFull(raw);
}
// ---------------------------------------------------------------------------
@@ -269,16 +275,18 @@ export async function getAdjacentTranscriptions(
}> {
if (!year) return { prev: null, next: null };
const yearTranscriptions = await prisma.transcription.findMany({
where: { year, isPublished: true },
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
select: SUMMARY_SELECT,
const res = await bff<{
prev: BffSummary | null;
next: BffSummary | null;
}>(`/transcriptions/adjacent/${slug}`, {
params: { year: String(year) },
tags: ["transcriptions"],
});
const idx = yearTranscriptions.findIndex((t) => t.slug === slug);
if (!res) return { prev: null, next: null };
return {
prev: idx > 0 ? yearTranscriptions[idx - 1] : null,
next:
idx < yearTranscriptions.length - 1 ? yearTranscriptions[idx + 1] : null,
prev: res.prev ? mapSummary(res.prev) : null,
next: res.next ? mapSummary(res.next) : null,
};
}
+31 -266
View File
@@ -1,280 +1,45 @@
/**
* Data access layer for Transcendental Teachings of Prahlāda Mahārāja (TTP).
*
* Reads from MongoDB via Prisma.
* TTP is a prose book with 1 intro segment (front-pages) and 5 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
* Delegates to the generic book data layer which calls the BFF API.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODE = "ttp";
const BOOK = "ttp";
export type TtpMeta = BookMeta;
export type TtpChapterEntry = { number: number; title: string };
export type TtpIntroSegment = IntroSegment;
export type TtpChapter = Chapter;
export type TtpSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TtpMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: TtpChapterEntry[];
};
export type TtpChapterEntry = {
number: number;
title: string;
};
export type TtpIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TtpChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TtpSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
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
// ---------------------------------------------------------------------------
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;
export async function getTtpMeta() {
return getBookMeta(CODE);
}
/** 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];
export async function getTtpIntroSegments() {
return getIntroSegments(CODE);
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getTtpIntroSegment(slug: string) {
return getIntroSegment(CODE, slug);
}
return result.map(sanitizeHtml);
export async function getTtpChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getTtpMeta(): Promise<TtpMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as TtpMeta;
export async function getTtpContent(slug: string) {
return getContent(CODE, slug);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTtpIntroSegments(): Promise<TtpIntroSegment[]> {
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 getTtpIntroSegment(
slug: string,
): Promise<TtpIntroSegment | 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–5).
*/
export async function getTtpChapter(
chapterNum: number,
): Promise<TtpChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !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-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getTtpContent(
slug: string,
): Promise<TtpChapter | TtpIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTtpIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getTtpChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getTtpSidebarItems(): Promise<TtpSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTtpMeta(),
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: TtpSidebarItem[] = [];
// 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–5 (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;
export async function getTtpSidebarItems() {
return getSidebarItems(CODE);
}
+22 -160
View File
@@ -1,167 +1,29 @@
/**
* Data access layer for Vaiśiṣṭyāṣṭakam (VA).
*
* Reads from MongoDB via Prisma.
* VA is a poetic work with 8 chapters (Vaiśiṣṭyas) and no intro segments.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import {
getBookMeta,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
const CODE = "va";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export type VaMeta = BookMeta;
export type VaChapterEntry = { number: number; title: string };
export type VaChapter = Chapter;
export type VaSidebarItem = SidebarItem;
const BOOK = "va";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type VaMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: VaChapterEntry[];
};
export type VaChapterEntry = {
number: number;
title: string;
};
export type VaChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Used in sidebar / table-of-contents navigation. */
export type VaSidebarItem = {
slug: string;
label: string;
type: string;
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
export async function getVaMeta() {
return getBookMeta(CODE);
}
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];
export async function getVaChapter(chapterNum: number) {
return getChapter(CODE, String(chapterNum));
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
export async function getVaContent(slug: string) {
return getContent(CODE, slug);
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
export async function getVaMeta(): Promise<VaMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as VaMeta;
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
export async function getVaChapter(
chapterNum: number,
): Promise<VaChapter | null> {
if (chapterNum < 1 || chapterNum > 8 || !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
// ---------------------------------------------------------------------------
export async function getVaContent(
slug: string,
): Promise<VaChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
return getVaChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
export async function getVaSidebarItems(): Promise<VaSidebarItem[]> {
const meta = await getVaMeta();
if (!meta) return [];
return meta.chapters.map((ch) => ({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
}));
export async function getVaSidebarItems() {
return getSidebarItems(CODE);
}
-5
View File
@@ -9,18 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
"@base-ui/react": "^1.4.0",
"@prisma/client": "^6.19.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"framer-motion": "^12.38.0",
"lucide-react": "^1.8.0",
"mongodb": "^7.1.1",
"next": "16.2.2",
"next-themes": "^0.4.6",
"prisma": "^6.19.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.2.0",
-9
View File
@@ -1,9 +0,0 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: process.env["DATABASE_URL"]!,
},
});
-398
View File
@@ -1,398 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Synonym — embedded document inside Verse
// ---------------------------------------------------------------------------
type Synonym {
word String
meanings String[]
}
// ---------------------------------------------------------------------------
// Verse — unified model for BG, SB, and CC verses
//
// book: "bg" | "sb" | "cc"
// division: null for BG, canto number string for SB ("1"-"10"),
// līlā key for CC ("adi", "madhya", "antya")
// chapter: chapter number
// verse: verse number (first verse number for multi-verse entries)
// verseEnd: last verse number for multi-verse entries (e.g. 18 for "16-18")
// reference: display reference string (e.g. "Bg 1.1", "SB 1.1.1", "Cc Ādi 1.1")
// ---------------------------------------------------------------------------
model Verse {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "bg" | "sb" | "cc"
division String? // null for BG; canto for SB; lila for CC
chapter Int
verse Int
verseEnd Int?
reference String
bookTitle String
devanagari String
bengali String? // only CC has Bengali
transliteration String
synonyms Synonym[]
translation String
purport String[]
@@unique([book, division, chapter, verse])
@@index([book])
@@index([book, division])
@@index([book, division, chapter])
@@map("verses")
}
// ---------------------------------------------------------------------------
// BookMeta — stores the intro.json data for each book
//
// The `data` field stores the entire intro JSON as a flexible JSON blob,
// since BG/SB/CC each have different structures (chapters vs cantos vs lilas).
// ---------------------------------------------------------------------------
model BookMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique // "bg" | "sb" | "cc"
data Json
@@map("book_meta")
}
// ---------------------------------------------------------------------------
// ProseChapter — for prose books like Kṛṣṇa Book (no verse structure)
//
// book: "kb"
// volume: volume number (1 or 2 for KB)
// chapter: chapter number within the volume
// title: chapter title
// type: "chapter" | "intro-segment"
// slug: URL-friendly identifier (e.g. "01", "preface", "words-from-apple")
// order: sort order (chapter number for chapters, custom for intro segments)
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model ProseChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "kb"
volume Int
chapter Int? // null for intro segments
title String
type String // "chapter" | "intro-segment"
slug String
order Int
paragraphs String[]
@@unique([book, volume, slug])
@@index([book])
@@index([book, volume])
@@index([book, volume, type])
@@map("prose_chapters")
}
// ---------------------------------------------------------------------------
// Essay — standalone essays (not part of any book's chapter structure)
//
// slug: URL-friendly identifier (e.g. "krishna-reservoir-of-pleasure")
// title: essay title
// author: author name
// year: publication year
// group: optional grouping key (e.g. "two-essays" for essays published together)
// order: sort order within a group
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model Essay {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
title String
author String
year Int?
group String?
order Int
paragraphs String[]
@@index([group])
@@map("essays")
}
// ---------------------------------------------------------------------------
// OtherDocumentMeta — metadata for miscellaneous documents
//
// book: slug identifier (e.g. "analysis-bg", "real-peace-formula")
// data: JSON blob containing title, author, year, description, chapters list
// ---------------------------------------------------------------------------
model OtherDocumentMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("other_document_meta")
}
// ---------------------------------------------------------------------------
// OtherDocumentChapter — chapters within other documents
//
// book: slug identifier matching OtherDocumentMeta.book
// chapter: chapter number
// title: chapter title
// type: "chapter"
// slug: URL-friendly identifier (chapter number as string)
// order: sort order
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model OtherDocumentChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@map("other_document_chapters")
}
// ---------------------------------------------------------------------------
// JournalMeta — metadata for journals (Jaladuta Diary, The Beginning, etc.)
//
// book: slug identifier (e.g. "jaladuta-diary", "the-beginning")
// data: JSON blob containing title, author, year, description, chapters list
// ---------------------------------------------------------------------------
model JournalMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("journal_meta")
}
// ---------------------------------------------------------------------------
// JournalChapter — chapters within journals
//
// book: slug identifier matching JournalMeta.book
// chapter: chapter number
// title: chapter title
// type: "chapter" | "preface" | "introduction"
// slug: URL-friendly identifier (chapter number as string)
// order: sort order
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model JournalChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@map("journal_chapters")
}
// ---------------------------------------------------------------------------
// MagazineMeta — metadata for magazines (Back to Godhead, etc.)
//
// book: slug identifier (e.g. "btg")
// data: JSON blob containing title, author, description, volumes/issues list
// ---------------------------------------------------------------------------
model MagazineMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("magazine_meta")
}
// ---------------------------------------------------------------------------
// MagazineIssue — individual issues within a magazine
//
// book: slug identifier matching MagazineMeta.book
// volume: volume number
// part: part/issue number within the volume
// title: display title (e.g. "Volume 3, Part 5, May 5, 1956")
// slug: URL-friendly identifier (e.g. "v3-p5")
// order: sort order
// paragraphs: array of HTML paragraph strings (all articles concatenated)
// ---------------------------------------------------------------------------
model MagazineIssue {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
volume Int?
part Int
title String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@index([book, volume])
@@map("magazine_issues")
}
// ---------------------------------------------------------------------------
// GlossaryEntry — individual glossary term + definition
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Letter — individual letters written by Śrīla Prabhupāda
//
// slug: unique identifier (e.g. "47-07-12-mahatma-gandhi", "68-01-15-hayagriva")
// recipients: array of individual recipient names (split from comma-separated)
// recipientDisplay: original recipient string for display (e.g. "Mukunda, Janaki")
// date: date string as written (e.g. "12 July, 1947")
// dateRef: reference code (e.g. "47-07-12")
// year: extracted year number for filtering/grouping
// month: extracted month number (1-12) for filtering
// day: extracted day number for filtering
// location: city/place where the letter was written
// salutation: the "Dear..." line
// paragraphs: array of HTML paragraph strings (letter body)
// ---------------------------------------------------------------------------
model Letter {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
recipients String[]
recipientDisplay String
date String?
dateRef String?
year Int?
month Int?
day Int?
location String?
salutation String?
paragraphs String[]
@@index([year])
@@index([recipients])
@@index([location])
@@index([year, month])
@@map("letters")
}
model GlossaryEntry {
id String @id @default(auto()) @map("_id") @db.ObjectId
term String
definition String
definitionHtml String
@@unique([term])
@@map("glossary")
}
// ---------------------------------------------------------------------------
// Disciple — disciples initiated by Śrīla Prabhupāda
//
// initiatedName: spiritual name given at initiation (e.g. "Acyutananda")
// suffix: initiation suffix (e.g. "Dasa", "Devi Dasi", "Sw")
// initiationDate: date of initiation (set to 1st of the month when exact day unknown)
// initiationPlace: place of initiation (e.g. "New York", "London")
// karmiName: pre-initiation (birth) name, if known
// avatar: URL to profile photo, if available
// about: biographical notes
// tags: array of tags for categorization
// isVerified: whether the profile has been manually verified with exact dates etc.
// notes: internal notes (not shown in UI)
// verifiedBy: name/identifier of who verified this profile (not shown in UI)
// ---------------------------------------------------------------------------
model Disciple {
id String @id @default(auto()) @map("_id") @db.ObjectId
initiatedName String
suffix String
initiationDate DateTime?
initiationPlace String?
karmiName String?
avatar String?
about String?
tags String[]
isVerified Boolean @default(false)
notes String?
verifiedBy String?
@@index([initiatedName])
@@index([initiationPlace])
@@index([initiationDate])
@@index([suffix])
@@index([tags])
@@map("disciples")
}
// ---------------------------------------------------------------------------
// ContentBlock — embedded document for rich text content within transcriptions
//
// text: the actual text content (may contain inline HTML for bold/italic)
// format: formatting hint — "paragraph" | "heading" | "quote" | "verse" | "note"
// alignment: text alignment — "left" | "center" | "right" (default: left)
// speaker: who is speaking this block (for dialogues — e.g., "Prabhupāda", "Devotee")
// ---------------------------------------------------------------------------
type ContentBlock {
text String
format String @default("paragraph")
alignment String @default("left")
speaker String?
}
// ---------------------------------------------------------------------------
// Transcription — lectures and conversations of Śrīla Prabhupāda
//
// slug: URL-friendly unique identifier (e.g., "720911-lecture-los-angeles")
// name: display title (e.g., "Lecture on Bhagavad-gītā 2.13")
// type: "lecture" | "conversation"
// date: date of the transcription
// year: extracted year for filtering/grouping
// month: extracted month (1-12) for filtering
// day: extracted day for filtering
// place: location where it was delivered (e.g., "Los Angeles", "Vṛndāvana")
// context: additional context (e.g., "Morning Walk", "Room Conversation", "Arrival Address")
// reference: scripture reference if applicable (e.g., "BG 2.13", "SB 1.2.6")
// summary: brief description of the content (for OG images, search results)
// content: array of ContentBlock embedded documents (the actual transcription)
// tags: array of tags for categorization (e.g., ["science", "reincarnation", "guru"])
// participants: array of named participants (e.g., ["Prabhupāda", "Bob Cohen", "Śyāmasundara"])
// language: primary language of the transcription (default: "en")
// source: origin of the transcription (e.g., "Vedabase", "BBT Archives")
// durationMinutes: approximate duration in minutes (if known)
// isPublished: whether the transcription is ready for public display
// notes: internal editorial notes (not shown in UI)
// ---------------------------------------------------------------------------
model Transcription {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
name String
type String // "lecture" | "conversation"
date String? // date as written (e.g., "September 11, 1972")
year Int?
month Int?
day Int?
place String?
context String?
reference String?
summary String?
content ContentBlock[]
tags String[]
participants String[]
language String @default("en")
source String?
durationMinutes Int?
audioUrl String?
isPublished Boolean @default(false)
notes String?
@@index([type])
@@index([year])
@@index([year, month])
@@index([place])
@@index([tags])
@@index([reference])
@@index([participants])
@@index([isPublished])
@@map("transcriptions")
}
+4 -1240
View File
File diff suppressed because it is too large Load Diff