From d88ababd7644a733f2cdab5b2da7d92aee7ddf2e Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 16 Apr 2026 00:48:48 +0530 Subject: [PATCH] feat: kb both volumes --- app/books/kb/[volume]/[slug]/page.tsx | 205 ++++++++++++++ app/books/kb/[volume]/page.tsx | 108 ++++++++ app/books/kb/page.tsx | 64 +++++ app/books/page.tsx | 28 +- lib/api.ts | 130 ++++++++- package-lock.json | 28 ++ prisma/schema.prisma | 30 +++ scripts/kb_extract_intro.py | 309 ++++++++++++++++++++++ scripts/kb_extract_vol1_chapters.py | 362 +++++++++++++++++++++++++ scripts/kb_extract_vol1_intro.py | 313 ++++++++++++++++++++++ scripts/kb_extract_vol2_chapters.py | 367 ++++++++++++++++++++++++++ scripts/seed-db.ts | 218 +++++++++++++-- scripts/seed-kb-vol2.ts | 149 +++++++++++ tsconfig.json | 12 +- yarn.lock | 15 +- 15 files changed, 2298 insertions(+), 40 deletions(-) create mode 100644 app/books/kb/[volume]/[slug]/page.tsx create mode 100644 app/books/kb/[volume]/page.tsx create mode 100644 app/books/kb/page.tsx create mode 100644 scripts/kb_extract_intro.py create mode 100644 scripts/kb_extract_vol1_chapters.py create mode 100644 scripts/kb_extract_vol1_intro.py create mode 100644 scripts/kb_extract_vol2_chapters.py create mode 100644 scripts/seed-kb-vol2.ts diff --git a/app/books/kb/[volume]/[slug]/page.tsx b/app/books/kb/[volume]/[slug]/page.tsx new file mode 100644 index 00000000..7efcdf66 --- /dev/null +++ b/app/books/kb/[volume]/[slug]/page.tsx @@ -0,0 +1,205 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getProseChapter, getProseChapters } from "@/lib/api"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent } from "@/components/ui/card"; +import { buttonVariants } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export default async function KbChapterPage({ + params, +}: { + params: Promise<{ volume: string; slug: string }>; +}) { + const { volume, slug } = await params; + const vol = parseInt(volume); + if (isNaN(vol) || vol < 1 || vol > 2) notFound(); + + const chapter = await getProseChapter("kb", vol, slug); + if (!chapter) notFound(); + + // Fetch all chapters/segments in this volume for sidebar + prev/next + const allChapters = await getProseChapters("kb", vol); + const currentIdx = allChapters.findIndex((ch) => ch.slug === slug); + + const prev = currentIdx > 0 ? allChapters[currentIdx - 1] : null; + const next = + currentIdx < allChapters.length - 1 ? allChapters[currentIdx + 1] : null; + + const chapterLabel = + chapter.type === "chapter" && chapter.chapter != null + ? `Chapter ${chapter.chapter}` + : null; + + return ( +
+ + + ← Volume {vol} + +

+ {chapterLabel && ( + {chapterLabel}: + )} + {chapter.title} +

+
+ +
+ {/* Left sidebar — chapter index */} + + + {/* Main content */} +
+ {/* Top navigation */} + + + {/* Chapter content */} + + +
+ {chapter.paragraphs.map((paragraph, i) => ( +
+ ))} +
+ + + + {/* Bottom navigation */} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Sidebar — chapter index +// --------------------------------------------------------------------------- + +type SidebarChapter = { + slug: string; + title: string; + type: string; + chapter: number | null; +}; + +function ChapterSidebar({ + chapters, + currentSlug, + volume, +}: { + chapters: SidebarChapter[]; + currentSlug: string; + volume: number; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Prev / Next navigation +// --------------------------------------------------------------------------- + +type NavChapter = { + slug: string; + title: string; + type: string; + chapter: number | null; +}; + +function ChapterNav({ + prev, + next, + volume, +}: { + prev: NavChapter | null; + next: NavChapter | null; + volume: number; +}) { + return ( +
+ {prev ? ( + + + + {prev.type === "chapter" && prev.chapter != null + ? `Ch. ${prev.chapter}` + : prev.title} + + Previous + + ) : ( +
+ )} + + {next ? ( + + + {next.type === "chapter" && next.chapter != null + ? `Ch. ${next.chapter}` + : next.title} + + Next + + + ) : ( +
+ )} +
+ ); +} diff --git a/app/books/kb/[volume]/page.tsx b/app/books/kb/[volume]/page.tsx new file mode 100644 index 00000000..eb5592e7 --- /dev/null +++ b/app/books/kb/[volume]/page.tsx @@ -0,0 +1,108 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getBookMeta, getProseChapters } from "@/lib/api"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent } from "@/components/ui/card"; + +export default async function KbVolumePage({ + params, +}: { + params: Promise<{ volume: string }>; +}) { + const { volume } = await params; + const vol = parseInt(volume); + if (isNaN(vol) || vol < 1 || vol > 2) notFound(); + + const meta = await getBookMeta("kb"); + if (!meta) notFound(); + + const title = meta.title as string; + + const [introSegments, chapters] = await Promise.all([ + getProseChapters("kb", vol, "intro-segment"), + getProseChapters("kb", vol, "chapter"), + ]); + + return ( +
+ + + ← {title} + +

+ Volume {vol} +

+

{chapters.length} chapters

+
+
+ {/* Front Matter */} + {introSegments.length > 0 && ( +
+

+ Front Matter +

+ + +
    + {introSegments.map((seg) => ( +
  • + +
    +

    + {seg.title} +

    +
    + + {seg.paragraphs.length} paragraphs + + +
  • + ))} +
+
+
+
+ )} + + {/* Chapters */} +
+

+ Chapters +

+ + +
    + {chapters.map((ch) => ( +
  • + +
    + + Chapter {ch.chapter} + +

    + {ch.title} +

    +
    + + {ch.paragraphs.length} paragraphs + + +
  • + ))} +
+
+
+
+
+
+ ); +} diff --git a/app/books/kb/page.tsx b/app/books/kb/page.tsx new file mode 100644 index 00000000..123ad0e6 --- /dev/null +++ b/app/books/kb/page.tsx @@ -0,0 +1,64 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getBookMeta } from "@/lib/api"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +type VolumeInfo = { + number: number; + totalChapters: number; +}; + +export default async function KbPage() { + const meta = await getBookMeta("kb"); + if (!meta) notFound(); + + const title = meta.title as string; + const author = meta.author as string; + const description = meta.description as string | undefined; + const volumes = meta.volumes as VolumeInfo[]; + + return ( +
+ + + ← Books + +

{title}

+

{author}

+
+
+ {description && ( +

{description}

+ )} + + +
    + {volumes.map((vol) => ( +
  • + +
    + + Volume {vol.number} + +

    + {vol.totalChapters} chapters +

    +
    + +
  • + ))} +
+
+
+
+
+ ); +} diff --git a/app/books/page.tsx b/app/books/page.tsx index c8b8db26..9a68e0dc 100644 --- a/app/books/page.tsx +++ b/app/books/page.tsx @@ -21,6 +21,12 @@ interface ChapterBookMeta extends BookMeta { chapters: ChapterEntry[]; } +interface KbMeta extends BookMeta { + totalChapters: number; + description: string; + volumes: { number: number; totalChapters: number }[]; +} + interface SbMeta extends BookMeta { totalCantos: number; cantos: CantoEntry[]; @@ -112,6 +118,16 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] { totalVerses, href: "/books/cc", }); + } else if (slug === "kb") { + const kb = data as KbMeta; + books.push({ + slug, + title, + author, + description: `${kb.totalChapters} chapters · 2 volumes`, + totalVerses: 0, + href: "/books/kb", + }); } } @@ -156,11 +172,13 @@ export default async function BooksPage() {
{book.description} - + {book.totalVerses > 0 && ( + + )}
diff --git a/lib/api.ts b/lib/api.ts index aa58efa2..e97d2a6f 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -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 { + const where: Record = { 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 { + 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: , , , ,
,
, . + */ +function sanitizeHtml(html: string): string { + // Remove , , + let clean = html.replace(/][\s\S]*?<\/script>/gi, ""); + clean = clean.replace(/][\s\S]*?<\/style>/gi, ""); + clean = clean.replace(/][\s\S]*?<\/iframe>/gi, ""); + // Remove lone