feat: sva extracted and seeded

This commit is contained in:
gosangam
2026-04-30 19:45:17 +05:30
parent 1f2ac2198a
commit ae6a752787
47 changed files with 17163 additions and 205 deletions
+357
View File
@@ -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;
}