feat: kb both volumes
This commit is contained in:
+129
-1
@@ -3,10 +3,11 @@
|
||||
*
|
||||
* 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 infinite-scroll fetching.
|
||||
* and for client-side verse-navigation fetching.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -56,6 +57,22 @@ export type GlossaryRecord = {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,6 +191,117 @@ export async function getSingleVerse(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user