feat: implement bff layer
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
© {new Date().getFullYear()}{" "}
|
||||
Team {" "}
|
||||
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
||||
</p>
|
||||
|
||||
|
||||
+211
-128
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getBbdChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getBkChapter(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 getBkContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fall back to prose content (intro segments, etc.)
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getCbhcChapter(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 getCbhcContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getDsIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getDsIntroSegment(slug: string) {
|
||||
return getIntroSegment(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),
|
||||
}));
|
||||
export async function getDsChapter(slug: string) {
|
||||
return getChapter(CODE, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getDsContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
|
||||
return getEjChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
|
||||
return getEkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getGgIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getGgIntroSegment(slug: string) {
|
||||
return getIntroSegment(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));
|
||||
export async function getGgChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
|
||||
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<GgIntroSegment>(filePath);
|
||||
export async function getGgContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getHkhIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getHkhIntroSegment(slug: string) {
|
||||
return getIntroSegment(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),
|
||||
}));
|
||||
export async function getHkhChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getHkhContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getIsoIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
|
||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
||||
return normalizeParagraphs(purport);
|
||||
export async function getIsoIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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");
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
||||
return getIsoVerse(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 → 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
@@ -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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getLclIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getLclIntroSegment(slug: string) {
|
||||
return getIntroSegment(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));
|
||||
export async function getLclChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
|
||||
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<LclIntroSegment>(filePath);
|
||||
export async function getLclContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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 Object.entries(counts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
return raw.map(mapLocationGroup).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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getLobIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getLobIntroSegment(slug: string) {
|
||||
return getIntroSegment(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));
|
||||
export async function getLobChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
|
||||
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<LobIntroSegment>(filePath);
|
||||
export async function getLobContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
|
||||
return getMgChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getMogIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getMogIntroSegment(slug: string) {
|
||||
return getIntroSegment(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));
|
||||
export async function getMogChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): MogIntroSegment | null {
|
||||
const filePath = path.join(MOG_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<MogIntroSegment>(filePath);
|
||||
export async function getMogContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getNodIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
export async function getNodIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getNodChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
// NodConcludingWords is just a chapter with slug "concluding-words"
|
||||
export async function getNodConcludingWords() {
|
||||
return getChapter(CODE, "concluding-words");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
};
|
||||
export async function getNodContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
|
||||
return getOchkmChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
+87
-55
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getOwkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getPkChapter(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 getPkContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
||||
return getPoyChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getPqpaIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
export async function getPqpaIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getPqpaChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
// PqpaConcludingWords is just a chapter with slug "concluding-words"
|
||||
export async function getPqpaConcludingWords() {
|
||||
return getChapter(CODE, "concluding-words");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
};
|
||||
export async function getPqpaContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
|
||||
return getRvChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getSsrIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getSsrIntroSegment(slug: string) {
|
||||
return getIntroSegment(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),
|
||||
}));
|
||||
export async function getSsrChapter(slug: string) {
|
||||
return getChapter(CODE, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getSsrContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getSvaIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getSvaIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
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);
|
||||
export async function getSvaChapter(slug: string) {
|
||||
return getChapter(CODE, slug);
|
||||
}
|
||||
|
||||
function getLocalSubChapter(slug: string): SvaChapter | null {
|
||||
const filePath = path.join(SVA_JSON_DIR, "chapters", `${slug}.json`);
|
||||
return readLocalJson<SvaChapter>(filePath);
|
||||
export async function getSvaContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 async function getSvaSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
export { getNavigableItems };
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getTlcIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getTlcIntroSegment(slug: string) {
|
||||
return getIntroSegment(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),
|
||||
}));
|
||||
export async function getTlcChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getTlcContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getTlkIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getTlkIntroSegment(slug: string) {
|
||||
return getIntroSegment(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));
|
||||
export async function getTlkChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): TlkIntroSegment | null {
|
||||
const filePath = path.join(TLK_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<TlkIntroSegment>(filePath);
|
||||
export async function getTlkContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getTqkIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getTqkIntroSegment(slug: string) {
|
||||
return getIntroSegment(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));
|
||||
export async function getTqkChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): TqkIntroSegment | null {
|
||||
const filePath = path.join(TQK_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<TqkIntroSegment>(filePath);
|
||||
export async function getTqkContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getTtpIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getTtpIntroSegment(slug: string) {
|
||||
return getIntroSegment(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),
|
||||
}));
|
||||
export async function getTtpChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getTtpContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
export async function getVaChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
export async function getVaContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]!,
|
||||
},
|
||||
});
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user