319 lines
8.6 KiB
TypeScript
319 lines
8.6 KiB
TypeScript
/**
|
|
* Data access layer for server components.
|
|
*
|
|
* These functions query MongoDB via Prisma and are called directly by
|
|
* Next.js server components. The /api/* routes exist for external consumers
|
|
* and for client-side verse-navigation fetching.
|
|
*/
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type BookMeta = {
|
|
book: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type VerseRecord = {
|
|
id: string;
|
|
book: string;
|
|
division: string | null;
|
|
chapter: number;
|
|
verse: number;
|
|
verseEnd: number | null;
|
|
reference: string;
|
|
bookTitle: string;
|
|
devanagari: string;
|
|
bengali: string | null;
|
|
transliteration: string;
|
|
synonyms: { word: string; meanings: string[] }[];
|
|
translation: string;
|
|
purport: string[];
|
|
};
|
|
|
|
export type VerseIndexEntry = {
|
|
verse: number;
|
|
verseEnd: number | null;
|
|
reference: string;
|
|
};
|
|
|
|
export type PaginatedVerses = {
|
|
verses: VerseRecord[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
export type GlossaryRecord = {
|
|
id: string;
|
|
term: string;
|
|
definition: string;
|
|
definitionHtml: string;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prose chapter types (for KB and other prose books)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ProseChapterRecord = {
|
|
id: string;
|
|
book: string;
|
|
volume: number;
|
|
chapter: number | null;
|
|
title: string;
|
|
type: string;
|
|
slug: string;
|
|
order: number;
|
|
paragraphs: string[];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Book meta
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get all book metas — each has { book, ...data } where data is the intro.json blob.
|
|
*/
|
|
export async function getAllBooks(): Promise<BookMeta[]> {
|
|
const metas = await prisma.bookMeta.findMany();
|
|
return metas.map((m) => ({
|
|
book: m.book,
|
|
...(m.data as Record<string, unknown>),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get a single book's meta by slug ("bg", "sb", "cc").
|
|
*/
|
|
export async function getBookMeta(book: string): Promise<BookMeta | null> {
|
|
const meta = await prisma.bookMeta.findUnique({ where: { book } });
|
|
if (!meta) return null;
|
|
return { book: meta.book, ...(meta.data as Record<string, unknown>) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Verse index (lightweight — for sidebar)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get the verse index for a chapter — returns only verse number, verseEnd,
|
|
* and reference for every verse. This is lightweight enough to fetch all at
|
|
* once even for chapters with 400+ verses (each entry is ~50 bytes).
|
|
*
|
|
* - BG: division is null
|
|
* - SB: division is canto number as string ("1", "2", …)
|
|
* - CC: division is līlā key ("adi", "madhya", "antya")
|
|
*/
|
|
export async function getChapterVerseIndex(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
): Promise<VerseIndexEntry[]> {
|
|
const entries = await prisma.verse.findMany({
|
|
where: { book, division, chapter },
|
|
orderBy: { verse: "asc" },
|
|
select: {
|
|
verse: true,
|
|
verseEnd: true,
|
|
reference: true,
|
|
},
|
|
});
|
|
|
|
return entries;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Verses (paginated)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
/**
|
|
* Get a page of verses for a given book + division + chapter.
|
|
*
|
|
* Returns the verses for the requested page along with pagination metadata
|
|
* so the client knows whether to fetch more.
|
|
*/
|
|
export async function getChapterVerses(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
page: number = 1,
|
|
limit: number = DEFAULT_PAGE_SIZE,
|
|
): Promise<PaginatedVerses> {
|
|
const skip = (page - 1) * limit;
|
|
|
|
const where = { book, division, chapter };
|
|
|
|
const [verses, total] = await Promise.all([
|
|
prisma.verse.findMany({
|
|
where,
|
|
orderBy: { verse: "asc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.verse.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
verses: verses as unknown as VerseRecord[],
|
|
total,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(total / limit),
|
|
hasMore: skip + verses.length < total,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single verse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get a single verse by its exact coordinates.
|
|
*/
|
|
export async function getSingleVerse(
|
|
book: string,
|
|
division: string | null,
|
|
chapter: number,
|
|
verse: number,
|
|
): Promise<VerseRecord | null> {
|
|
const result = await prisma.verse.findFirst({
|
|
where: { book, division, chapter, verse },
|
|
});
|
|
|
|
return result as unknown as VerseRecord | null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prose chapters (KB and other prose books)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get all prose chapters/segments for a given book and volume,
|
|
* optionally filtered by type ("chapter" | "intro-segment").
|
|
* Ordered by the `order` field.
|
|
*/
|
|
export async function getProseChapters(
|
|
book: string,
|
|
volume: number,
|
|
type?: string,
|
|
): Promise<ProseChapterRecord[]> {
|
|
const where: Record<string, unknown> = { book, volume };
|
|
if (type) where.type = type;
|
|
|
|
const results = await prisma.proseChapter.findMany({
|
|
where,
|
|
orderBy: { order: "asc" },
|
|
});
|
|
|
|
return results.map(normalizeProse);
|
|
}
|
|
|
|
/**
|
|
* Get a single prose chapter by book + volume + slug.
|
|
*/
|
|
export async function getProseChapter(
|
|
book: string,
|
|
volume: number,
|
|
slug: string,
|
|
): Promise<ProseChapterRecord | null> {
|
|
const result = await prisma.proseChapter.findUnique({
|
|
where: {
|
|
book_volume_slug: { book, volume, slug },
|
|
},
|
|
});
|
|
|
|
return result ? normalizeProse(result) : null;
|
|
}
|
|
|
|
/**
|
|
* Normalize a prose chapter from the DB.
|
|
* Some chapters were stored with paragraphs as a JSON string (fallback
|
|
* for BSON wire-protocol corruption). This normalizes them back to arrays.
|
|
*/
|
|
function normalizeProse(doc: {
|
|
id: string;
|
|
book: string;
|
|
volume: number;
|
|
chapter: number | null;
|
|
title: string;
|
|
type: string;
|
|
slug: string;
|
|
order: number;
|
|
paragraphs: string[] | JsonValue;
|
|
}): ProseChapterRecord {
|
|
let paragraphs: string[];
|
|
|
|
if (typeof doc.paragraphs === "string") {
|
|
// Was stored as stringified JSON — parse it back
|
|
try {
|
|
paragraphs = JSON.parse(doc.paragraphs);
|
|
} catch {
|
|
paragraphs = [doc.paragraphs];
|
|
}
|
|
} else if (Array.isArray(doc.paragraphs)) {
|
|
paragraphs = doc.paragraphs as string[];
|
|
} else {
|
|
paragraphs = [];
|
|
}
|
|
|
|
// Sanitize HTML — strip dangerous tags and event handlers
|
|
paragraphs = paragraphs.map(sanitizeHtml);
|
|
|
|
return {
|
|
id: doc.id,
|
|
book: doc.book,
|
|
volume: doc.volume,
|
|
chapter: doc.chapter,
|
|
title: doc.title,
|
|
type: doc.type,
|
|
slug: doc.slug,
|
|
order: doc.order,
|
|
paragraphs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sanitize an HTML string by stripping dangerous tags and attributes.
|
|
* Keeps safe formatting tags: <i>, <b>, <em>, <strong>, <br>, <div>, <span>.
|
|
*/
|
|
function sanitizeHtml(html: string): string {
|
|
// Remove <script>...</script>, <style>...</style>, <iframe>...</iframe>
|
|
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
// Remove lone <script>, <style>, <iframe> opening/closing tags
|
|
clean = clean.replace(
|
|
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
"",
|
|
);
|
|
// Remove event handler attributes (onclick, onload, onerror, etc.)
|
|
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
// Remove javascript: URLs
|
|
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
return clean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Glossary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get all glossary entries, ordered alphabetically by term.
|
|
*/
|
|
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
|
const entries = await prisma.glossaryEntry.findMany({
|
|
orderBy: { term: "asc" },
|
|
});
|
|
|
|
return entries as unknown as GlossaryRecord[];
|
|
}
|