feat: sva extracted and seeded
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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"
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "bs";
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text title lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 62) {
|
||||
return getBsVerse(String(num));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: Introduction → Foreword → Texts 1-62
|
||||
*/
|
||||
export async function getBsSidebarItems(): Promise<BsSidebarItem[]> {
|
||||
const items: BsSidebarItem[] = [];
|
||||
|
||||
// 1. Introduction intro segment
|
||||
items.push({
|
||||
slug: "intro-introduction",
|
||||
label: "Introduction",
|
||||
type: "intro-segment",
|
||||
text: null,
|
||||
});
|
||||
|
||||
// 2. Foreword intro segment
|
||||
items.push({
|
||||
slug: "intro-foreword",
|
||||
label: "Foreword",
|
||||
type: "intro-segment",
|
||||
text: null,
|
||||
});
|
||||
|
||||
// 3. Texts 1-62 (verses)
|
||||
for (let i = 1; i <= 62; i++) {
|
||||
items.push({
|
||||
slug: String(i),
|
||||
label: `${i}. ${TEXT_TITLES[i]}`,
|
||||
type: "verse",
|
||||
text: i,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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 { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "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;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local JSON helpers (fallback when DB is not seeded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readLocalJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): GgIntroSegment[] {
|
||||
const introDir = path.join(GG_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: GgIntroSegment[] = [];
|
||||
const files = ["introduction.json"];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
const seg = readLocalJson<GgIntroSegment>(filePath);
|
||||
if (seg) results.push(seg);
|
||||
}
|
||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
|
||||
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<GgIntroSegment>(filePath);
|
||||
}
|
||||
|
||||
function getLocalChapter(chapterNum: number): GgChapter | null {
|
||||
const filePath = path.join(
|
||||
GG_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0") + ".json",
|
||||
);
|
||||
return readLocalJson<GgChapter>(filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getGgMeta(): Promise<GgMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (meta) return meta.data as unknown as GgMeta;
|
||||
|
||||
// Fallback to local JSON
|
||||
const introPath = path.join(GG_JSON_DIR, "intro.json");
|
||||
return readLocalJson<GgMeta>(introPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getGgIntroSegments(): Promise<GgIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
return getLocalIntroSegments();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
|
||||
*/
|
||||
export async function getGgIntroSegment(
|
||||
slug: string,
|
||||
): Promise<GgIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalIntroSegment(rawSlug);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
slug: dbSlug,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–18).
|
||||
*/
|
||||
export async function getGgChapter(
|
||||
chapterNum: number,
|
||||
): Promise<GgChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalChapter(chapterNum);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-introduction" → intro segment
|
||||
* "1" … "18" → chapter
|
||||
*/
|
||||
export async function getGgContent(
|
||||
slug: string,
|
||||
): Promise<GgChapter | GgIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getGgIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
||||
return getGgChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: introduction → chapters 1–18
|
||||
*/
|
||||
export async function getGgSidebarItems(): Promise<GgSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getGgMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// If we got DB results, build from those
|
||||
if (meta && allDocs.length > 0) {
|
||||
return buildSidebarFromDocs(meta, allDocs);
|
||||
}
|
||||
|
||||
// Fallback: build from local JSON
|
||||
return buildSidebarFromLocal(meta);
|
||||
}
|
||||
|
||||
function buildSidebarFromDocs(
|
||||
meta: GgMeta,
|
||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
||||
): GgSidebarItem[] {
|
||||
const items: GgSidebarItem[] = [];
|
||||
|
||||
// Intro segments that come BEFORE chapters (order < 99)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–18 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Intro segments that come AFTER chapters (order >= 99)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSidebarFromLocal(meta: GgMeta | null): GgSidebarItem[] {
|
||||
const items: GgSidebarItem[] = [];
|
||||
|
||||
// Local intro segments (front matter)
|
||||
const introSegs = getLocalIntroSegments();
|
||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
|
||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
|
||||
|
||||
for (const seg of frontMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Chapters from meta or generate default 1-18
|
||||
if (meta && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: scan local chapter JSONs
|
||||
const chaptersDir = path.join(GG_JSON_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const files = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const ch = readLocalJson<GgChapter>(path.join(chaptersDir, file));
|
||||
if (ch) {
|
||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
items.push({
|
||||
slug: String(num),
|
||||
label: `Chapter ${num}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back matter
|
||||
for (const seg of backMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "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;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local JSON helpers (fallback when DB is not seeded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readLocalJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): LclIntroSegment[] {
|
||||
const introDir = path.join(LCL_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: LclIntroSegment[] = [];
|
||||
const files = ["foreword.json", "introduction.json", "about-the-author.json"];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
const seg = readLocalJson<LclIntroSegment>(filePath);
|
||||
if (seg) results.push(seg);
|
||||
}
|
||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
|
||||
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<LclIntroSegment>(filePath);
|
||||
}
|
||||
|
||||
function getLocalChapter(chapterNum: number): LclChapter | null {
|
||||
const filePath = path.join(
|
||||
LCL_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0") + ".json",
|
||||
);
|
||||
return readLocalJson<LclChapter>(filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getLclMeta(): Promise<LclMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (meta) return meta.data as unknown as LclMeta;
|
||||
|
||||
// Fallback to local JSON
|
||||
const introPath = path.join(LCL_JSON_DIR, "intro.json");
|
||||
return readLocalJson<LclMeta>(introPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getLclIntroSegments(): Promise<LclIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
return getLocalIntroSegments();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
|
||||
*/
|
||||
export async function getLclIntroSegment(
|
||||
slug: string,
|
||||
): Promise<LclIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalIntroSegment(rawSlug);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
slug: dbSlug,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–16).
|
||||
*/
|
||||
export async function getLclChapter(
|
||||
chapterNum: number,
|
||||
): Promise<LclChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 16 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalChapter(chapterNum);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-foreword" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "intro-about-the-author" → intro segment
|
||||
* "1" … "16" → chapter
|
||||
*/
|
||||
export async function getLclContent(
|
||||
slug: string,
|
||||
): Promise<LclChapter | LclIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getLclIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 16) {
|
||||
return getLclChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: foreword → introduction → chapters 1–16 → about-the-author
|
||||
*/
|
||||
export async function getLclSidebarItems(): Promise<LclSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getLclMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// If we got DB results, build from those
|
||||
if (meta && allDocs.length > 0) {
|
||||
return buildSidebarFromDocs(meta, allDocs);
|
||||
}
|
||||
|
||||
// Fallback: build from local JSON
|
||||
return buildSidebarFromLocal(meta);
|
||||
}
|
||||
|
||||
function buildSidebarFromDocs(
|
||||
meta: LclMeta,
|
||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
||||
): LclSidebarItem[] {
|
||||
const items: LclSidebarItem[] = [];
|
||||
|
||||
// Intro segments that come BEFORE chapters (order < 99)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–16 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSidebarFromLocal(meta: LclMeta | null): LclSidebarItem[] {
|
||||
const items: LclSidebarItem[] = [];
|
||||
|
||||
// Local intro segments (front matter)
|
||||
const introSegs = getLocalIntroSegments();
|
||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
|
||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
|
||||
|
||||
for (const seg of frontMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Chapters from meta or generate default 1-16
|
||||
if (meta && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: scan local chapter JSONs
|
||||
const chaptersDir = path.join(LCL_JSON_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const files = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const ch = readLocalJson<LclChapter>(path.join(chaptersDir, file));
|
||||
if (ch) {
|
||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
items.push({
|
||||
slug: String(num),
|
||||
label: `Chapter ${num}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back matter (about-the-author)
|
||||
for (const seg of backMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "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;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local JSON helpers (fallback when DB is not seeded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readLocalJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): LobIntroSegment[] {
|
||||
const introDir = path.join(LOB_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: LobIntroSegment[] = [];
|
||||
const files = [
|
||||
"front-pages.json",
|
||||
"preface.json",
|
||||
"acknowledgement.json",
|
||||
"the-artist.json",
|
||||
];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
const seg = readLocalJson<LobIntroSegment>(filePath);
|
||||
if (seg) results.push(seg);
|
||||
}
|
||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
|
||||
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<LobIntroSegment>(filePath);
|
||||
}
|
||||
|
||||
function getLocalChapter(chapterNum: number): LobChapter | null {
|
||||
const filePath = path.join(
|
||||
LOB_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0") + ".json",
|
||||
);
|
||||
return readLocalJson<LobChapter>(filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getLobMeta(): Promise<LobMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (meta) return meta.data as unknown as LobMeta;
|
||||
|
||||
// Fallback to local JSON
|
||||
const introPath = path.join(LOB_JSON_DIR, "intro.json");
|
||||
return readLocalJson<LobMeta>(introPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getLobIntroSegments(): Promise<LobIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
return getLocalIntroSegments();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getLobIntroSegment(
|
||||
slug: string,
|
||||
): Promise<LobIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalIntroSegment(rawSlug);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
slug: dbSlug,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters (Texts 1–48)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered text/chapter (1–48).
|
||||
*/
|
||||
export async function getLobChapter(
|
||||
chapterNum: number,
|
||||
): Promise<LobChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 48 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalChapter(chapterNum);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "intro-acknowledgement" → intro segment
|
||||
* "intro-the-artist" → intro segment
|
||||
* "1" … "48" → chapter (text)
|
||||
*/
|
||||
export async function getLobContent(
|
||||
slug: string,
|
||||
): Promise<LobChapter | LobIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getLobIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 48) {
|
||||
return getLobChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → preface → acknowledgement → the-artist → texts 1–48
|
||||
*/
|
||||
export async function getLobSidebarItems(): Promise<LobSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getLobMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// If we got DB results, build from those
|
||||
if (meta && allDocs.length > 0) {
|
||||
return buildSidebarFromDocs(meta, allDocs);
|
||||
}
|
||||
|
||||
// Fallback: build from local JSON
|
||||
return buildSidebarFromLocal(meta);
|
||||
}
|
||||
|
||||
function buildSidebarFromDocs(
|
||||
meta: LobMeta,
|
||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
||||
): LobSidebarItem[] {
|
||||
const items: LobSidebarItem[] = [];
|
||||
|
||||
// Intro segments (all come before chapters; LoB has no back matter)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Texts 1–48 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Text ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSidebarFromLocal(meta: LobMeta | null): LobSidebarItem[] {
|
||||
const items: LobSidebarItem[] = [];
|
||||
|
||||
// Local intro segments (front matter only — LoB has no back matter)
|
||||
const introSegs = getLocalIntroSegments();
|
||||
for (const seg of introSegs) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Texts from meta or generate default 1-48
|
||||
if (meta && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Text ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: scan local chapter JSONs
|
||||
const chaptersDir = path.join(LOB_JSON_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const files = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const ch = readLocalJson<LobChapter>(path.join(chaptersDir, file));
|
||||
if (ch) {
|
||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
items.push({
|
||||
slug: String(num),
|
||||
label: `Text ${num}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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 prose/song book with 4 parts (chapters) plus 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/sva/.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "sva";
|
||||
|
||||
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: SvaChapterEntry[];
|
||||
};
|
||||
|
||||
export type SvaChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type SvaIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type SvaChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type SvaSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "intro-foreword", "1", "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 (mirrors lib/api.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local JSON helpers (fallback when DB is not seeded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readLocalJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function 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);
|
||||
}
|
||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): SvaIntroSegment | null {
|
||||
const filePath = path.join(SVA_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<SvaIntroSegment>(filePath);
|
||||
}
|
||||
|
||||
function getLocalChapter(chapterNum: number): SvaChapter | null {
|
||||
const filePath = path.join(
|
||||
SVA_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0") + ".json",
|
||||
);
|
||||
return readLocalJson<SvaChapter>(filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getSvaMeta(): Promise<SvaMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (meta) return meta.data as unknown as SvaMeta;
|
||||
|
||||
// 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[]> {
|
||||
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 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;
|
||||
|
||||
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–4).
|
||||
*/
|
||||
export async function getSvaChapter(
|
||||
chapterNum: number,
|
||||
): Promise<SvaChapter | 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.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-foreword" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "1" … "4" → chapter (part)
|
||||
*/
|
||||
export async function getSvaContent(
|
||||
slug: string,
|
||||
): Promise<SvaChapter | SvaIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getSvaIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
|
||||
return getSvaChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → foreword → introduction → parts 1–4
|
||||
*/
|
||||
export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getSvaMeta(),
|
||||
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: SvaMeta,
|
||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
||||
): SvaSidebarItem[] {
|
||||
const items: SvaSidebarItem[] = [];
|
||||
|
||||
// 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–4 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Part ${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: SvaMeta | null): SvaSidebarItem[] {
|
||||
const items: SvaSidebarItem[] = [];
|
||||
|
||||
// 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-4
|
||||
if (meta && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Part ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: scan local chapter JSONs
|
||||
const chaptersDir = path.join(SVA_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<SvaChapter>(path.join(chaptersDir, file));
|
||||
if (ch) {
|
||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
items.push({
|
||||
slug: String(num),
|
||||
label: `Part ${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;
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "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;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local JSON helpers (fallback when DB is not seeded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readLocalJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): TlkIntroSegment[] {
|
||||
const introDir = path.join(TLK_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: TlkIntroSegment[] = [];
|
||||
const files = [
|
||||
"front-pages.json",
|
||||
"foreword.json",
|
||||
"about-the-author.json",
|
||||
"credits.json",
|
||||
"back-flap.json",
|
||||
];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
const seg = readLocalJson<TlkIntroSegment>(filePath);
|
||||
if (seg) results.push(seg);
|
||||
}
|
||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): TlkIntroSegment | null {
|
||||
const filePath = path.join(TLK_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<TlkIntroSegment>(filePath);
|
||||
}
|
||||
|
||||
function getLocalChapter(chapterNum: number): TlkChapter | null {
|
||||
const filePath = path.join(
|
||||
TLK_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0") + ".json",
|
||||
);
|
||||
return readLocalJson<TlkChapter>(filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getTlkMeta(): Promise<TlkMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (meta) return meta.data as unknown as TlkMeta;
|
||||
|
||||
// Fallback to local JSON
|
||||
const introPath = path.join(TLK_JSON_DIR, "intro.json");
|
||||
return readLocalJson<TlkMeta>(introPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getTlkIntroSegments(): Promise<TlkIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
return getLocalIntroSegments();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
|
||||
*/
|
||||
export async function getTlkIntroSegment(
|
||||
slug: string,
|
||||
): Promise<TlkIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalIntroSegment(rawSlug);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
slug: dbSlug,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–18).
|
||||
*/
|
||||
export async function getTlkChapter(
|
||||
chapterNum: number,
|
||||
): Promise<TlkChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalChapter(chapterNum);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-foreword" → intro segment
|
||||
* "intro-about-the-author" → intro segment
|
||||
* "intro-credits" → intro segment
|
||||
* "intro-back-flap" → intro segment
|
||||
* "1" … "18" → chapter
|
||||
*/
|
||||
export async function getTlkContent(
|
||||
slug: string,
|
||||
): Promise<TlkChapter | TlkIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getTlkIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
||||
return getTlkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → foreword → chapters 1–18 → about-the-author → credits → back-flap
|
||||
*/
|
||||
export async function getTlkSidebarItems(): Promise<TlkSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getTlkMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// If we got DB results, build from those
|
||||
if (meta && allDocs.length > 0) {
|
||||
return buildSidebarFromDocs(meta, allDocs);
|
||||
}
|
||||
|
||||
// Fallback: build from local JSON
|
||||
return buildSidebarFromLocal(meta);
|
||||
}
|
||||
|
||||
function buildSidebarFromDocs(
|
||||
meta: TlkMeta,
|
||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
||||
): TlkSidebarItem[] {
|
||||
const items: TlkSidebarItem[] = [];
|
||||
|
||||
// Intro segments that come BEFORE chapters (order < 50)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–18 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Intro segments that come AFTER chapters (order >= 50, e.g. about-the-author, credits, back-flap)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSidebarFromLocal(meta: TlkMeta | null): TlkSidebarItem[] {
|
||||
const items: TlkSidebarItem[] = [];
|
||||
|
||||
// Local intro segments (front matter)
|
||||
const introSegs = getLocalIntroSegments();
|
||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50);
|
||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50);
|
||||
|
||||
for (const seg of frontMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Chapters from meta or generate default 1-18
|
||||
if (meta && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: scan local chapter JSONs
|
||||
const chaptersDir = path.join(TLK_JSON_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const files = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const ch = readLocalJson<TlkChapter>(path.join(chaptersDir, file));
|
||||
if (ch) {
|
||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
items.push({
|
||||
slug: String(num),
|
||||
label: `Chapter ${num}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back matter (about-the-author, credits, back-flap)
|
||||
for (const seg of backMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "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;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local JSON helpers (fallback when DB is not seeded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readLocalJson<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): TqkIntroSegment[] {
|
||||
const introDir = path.join(TQK_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: TqkIntroSegment[] = [];
|
||||
const files = [
|
||||
"front-pages.json",
|
||||
"introduction.json",
|
||||
"back-flap.json",
|
||||
];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
const seg = readLocalJson<TqkIntroSegment>(filePath);
|
||||
if (seg) results.push(seg);
|
||||
}
|
||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): TqkIntroSegment | null {
|
||||
const filePath = path.join(TQK_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<TqkIntroSegment>(filePath);
|
||||
}
|
||||
|
||||
function getLocalChapter(chapterNum: number): TqkChapter | null {
|
||||
const filePath = path.join(
|
||||
TQK_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0") + ".json",
|
||||
);
|
||||
return readLocalJson<TqkChapter>(filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getTqkMeta(): Promise<TqkMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (meta) return meta.data as unknown as TqkMeta;
|
||||
|
||||
// Fallback to local JSON
|
||||
const introPath = path.join(TQK_JSON_DIR, "intro.json");
|
||||
return readLocalJson<TqkMeta>(introPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getTqkIntroSegments(): Promise<TqkIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
return getLocalIntroSegments();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
|
||||
*/
|
||||
export async function getTqkIntroSegment(
|
||||
slug: string,
|
||||
): Promise<TqkIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalIntroSegment(rawSlug);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
slug: dbSlug,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–26).
|
||||
*/
|
||||
export async function getTqkChapter(
|
||||
chapterNum: number,
|
||||
): Promise<TqkChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 26 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to local JSON
|
||||
const local = getLocalChapter(chapterNum);
|
||||
if (!local) return null;
|
||||
return {
|
||||
...local,
|
||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "intro-back-flap" → intro segment
|
||||
* "1" … "26" → chapter
|
||||
*/
|
||||
export async function getTqkContent(
|
||||
slug: string,
|
||||
): Promise<TqkChapter | TqkIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getTqkIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 26) {
|
||||
return getTqkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → introduction → chapters 1–26 → back-flap
|
||||
*/
|
||||
export async function getTqkSidebarItems(): Promise<TqkSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getTqkMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// If we got DB results, build from those
|
||||
if (meta && allDocs.length > 0) {
|
||||
return buildSidebarFromDocs(meta, allDocs);
|
||||
}
|
||||
|
||||
// Fallback: build from local JSON
|
||||
return buildSidebarFromLocal(meta);
|
||||
}
|
||||
|
||||
function buildSidebarFromDocs(
|
||||
meta: TqkMeta,
|
||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
||||
): TqkSidebarItem[] {
|
||||
const items: TqkSidebarItem[] = [];
|
||||
|
||||
// Intro segments that come BEFORE chapters (order < 50)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–26 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Intro segments that come AFTER chapters (order >= 50, e.g. back-flap)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSidebarFromLocal(meta: TqkMeta | null): TqkSidebarItem[] {
|
||||
const items: TqkSidebarItem[] = [];
|
||||
|
||||
// Local intro segments (front matter)
|
||||
const introSegs = getLocalIntroSegments();
|
||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50);
|
||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50);
|
||||
|
||||
for (const seg of frontMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Chapters from meta or generate default 1-26
|
||||
if (meta && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: scan local chapter JSONs
|
||||
const chaptersDir = path.join(TQK_JSON_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const files = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const ch = readLocalJson<TqkChapter>(path.join(chaptersDir, file));
|
||||
if (ch) {
|
||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
items.push({
|
||||
slug: String(num),
|
||||
label: `Chapter ${num}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back matter (back-flap)
|
||||
for (const seg of backMatter) {
|
||||
items.push({
|
||||
slug: `intro-${seg.slug}`,
|
||||
label: seg.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
Reference in New Issue
Block a user