feat: kb both volumes

This commit is contained in:
gosangam
2026-04-16 00:48:48 +05:30
parent abb7cc49d0
commit d88ababd76
15 changed files with 2298 additions and 40 deletions
+205
View File
@@ -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 (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/books/kb/${vol}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Volume {vol}
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterLabel && (
<span className="text-primary">{chapterLabel}: </span>
)}
{chapter.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar — chapter index */}
<ChapterSidebar
chapters={allChapters}
currentSlug={slug}
volume={vol}
/>
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<ChapterNav prev={prev} next={next} volume={vol} />
{/* Chapter content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{chapter.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<ChapterNav prev={prev} next={next} volume={vol} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapters.map((ch) => {
const isActive = ch.slug === currentSlug;
const label =
ch.type === "chapter" && ch.chapter != null
? `${ch.chapter}. ${ch.title}`
: ch.title;
return (
<Link
key={ch.slug}
href={`/books/kb/${volume}/${ch.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
})}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/kb/${volume}/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.title}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div />
)}
{next ? (
<Link
href={`/books/kb/${volume}/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.title}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+108
View File
@@ -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 (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/kb"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {title}
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Volume {vol}
</h1>
<p className="mt-1 text-muted-foreground">{chapters.length} chapters</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/kb/${vol}/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{chapters.map((ch) => (
<li key={ch.slug}>
<Link
href={`/books/kb/${vol}/${ch.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.chapter}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{ch.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+64
View File
@@ -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 (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
{description && (
<p className="text-muted-foreground mb-6">{description}</p>
)}
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{volumes.map((vol) => (
<li key={vol.number}>
<Link
href={`/books/kb/${vol.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Volume {vol.number}
</Badge>
<p className="font-medium text-foreground">
{vol.totalChapters} chapters
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+18
View File
@@ -21,6 +21,12 @@ interface ChapterBookMeta extends BookMeta {
chapters: ChapterEntry[]; chapters: ChapterEntry[];
} }
interface KbMeta extends BookMeta {
totalChapters: number;
description: string;
volumes: { number: number; totalChapters: number }[];
}
interface SbMeta extends BookMeta { interface SbMeta extends BookMeta {
totalCantos: number; totalCantos: number;
cantos: CantoEntry[]; cantos: CantoEntry[];
@@ -112,6 +118,16 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
totalVerses, totalVerses,
href: "/books/cc", 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() {
<CardContent> <CardContent>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{book.description}</Badge> <Badge variant="secondary">{book.description}</Badge>
{book.totalVerses > 0 && (
<ExtractedVerseCount <ExtractedVerseCount
count={book.totalVerses} count={book.totalVerses}
label="extracted verses" label="extracted verses"
className="text-muted-foreground" className="text-muted-foreground"
/> />
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+129 -1
View File
@@ -3,10 +3,11 @@
* *
* These functions query MongoDB via Prisma and are called directly by * These functions query MongoDB via Prisma and are called directly by
* Next.js server components. The /api/* routes exist for external consumers * 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 { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -56,6 +57,22 @@ export type GlossaryRecord = {
definitionHtml: 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 // Book meta
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -174,6 +191,117 @@ export async function getSingleVerse(
return result as unknown as VerseRecord | null; 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 // Glossary
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+28
View File
@@ -8362,6 +8362,34 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+30
View File
@@ -63,6 +63,36 @@ model BookMeta {
@@map("book_meta") @@map("book_meta")
} }
// ---------------------------------------------------------------------------
// ProseChapter — for prose books like Kṛṣṇa Book (no verse structure)
//
// book: "kb"
// volume: volume number (1 or 2 for KB)
// chapter: chapter number within the volume
// title: chapter title
// type: "chapter" | "intro-segment"
// slug: URL-friendly identifier (e.g. "01", "preface", "words-from-apple")
// order: sort order (chapter number for chapters, custom for intro segments)
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model ProseChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "kb"
volume Int
chapter Int? // null for intro segments
title String
type String // "chapter" | "intro-segment"
slug String
order Int
paragraphs String[]
@@unique([book, volume, slug])
@@index([book])
@@index([book, volume])
@@index([book, volume, type])
@@map("prose_chapters")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GlossaryEntry — individual glossary term + definition // GlossaryEntry — individual glossary term + definition
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Extract Kṛṣṇa Book introduction and build intro.json with chapter listing.
Reads public/html/other.html to extract:
1. The introduction/preface text (lines ~713–771)
2. The full chapter listing from p13 bookmark tags (KB 1970-V-C / Title)
3. Chapter end markers ("Thus ends the Bhaktivedanta purport of the Nth Chapter...")
Output: lib/jsons/books/kb/intro.json
Usage:
python3 scripts/kb_extract_intro.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
inner = re.sub(r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner)
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
return decode_scagoudy(tag.get_text().strip())
def get_css_class(tag: Tag) -> str:
classes = tag.get("class", [])
return classes[0] if classes else ""
# ---------------------------------------------------------------------------
# Extract introduction paragraphs (lines ~713–771)
# ---------------------------------------------------------------------------
# CSS classes used for Sanskrit verse pādas in the introduction
VERSE_PADA_CLASSES = frozenset(["p4", "p5", "p10"])
def extract_introduction(soup: BeautifulSoup) -> list[str]:
"""Extract the Kṛṣṇa Book introduction/preface paragraphs.
Starts at the 'KṚṢṆA BOOK-Introduction' header and ends at the
'February 26th, 1970' date line (first occurrence — the undated
typed document version).
Sanskrit verse lines (p4/p5 classes) are grouped into a single
center-aligned italic block.
"""
paragraphs = soup.find_all("p")
intro_paragraphs: list[str] = []
in_intro = False
found_date = False
verse_accumulator: list[str] = []
def flush_verse():
"""Join accumulated Sanskrit verse pādas into one formatted block."""
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
intro_paragraphs.append(
f'<div style="text-align:center"><i>{joined}</i></div>'
)
verse_accumulator.clear()
for p in paragraphs:
cls = get_css_class(p)
text = p.get_text().strip()
# Start marker
if not in_intro:
if "BOOK-Introduction" in text or "BOOK\u2011Introduction" in text:
in_intro = True
continue
# Also match the ScaGoudy-encoded version
if "BOOK" in text and "Introduction" in text and "K" in text:
decoded = decode_text(p)
if "KṚṢṆA" in decoded and "Introduction" in decoded:
in_intro = True
continue
continue
# Skip the "Preface" subheader
if text == "Preface" or text.startswith("*****"):
continue
# End marker — "February 26th, 1970" (first occurrence)
if "February 26th, 1970" in text:
flush_verse()
found_date = True
break
# Skip empty paragraphs
if not text or text == "\xa0":
continue
# Sanskrit verse pādas (p4, p5, p10) — accumulate into a single block
# Use decode_html_content to preserve <br> tags within multi-line verse elements
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
verse_accumulator.append(html)
continue
# If we were accumulating verse lines and hit a non-verse paragraph,
# flush the verse block first
flush_verse()
# Collect the paragraph
html = decode_html_content(p)
if html:
intro_paragraphs.append(html)
if not found_date:
print(" WARNING: Did not find the end marker 'February 26th, 1970'")
return intro_paragraphs
# ---------------------------------------------------------------------------
# Extract chapter listing from p13 bookmarks
# ---------------------------------------------------------------------------
def extract_chapters(soup: BeautifulSoup) -> list[dict]:
"""Extract the full chapter listing from p13 bookmark tags.
Format: 'KB 1970-V-C / Title' where V=volume, C=chapter within volume.
We produce a flat chapter numbering (1–89) across both volumes.
Also finds chapter end markers to count paragraphs per chapter
(useful for later extraction).
"""
chapters: list[dict] = []
chapter_num = 0
# Also extract chapter end markers for titles
end_titles: dict[int, str] = {}
for p in soup.find_all("p"):
cls = get_css_class(p)
text = decode_text(p)
# "Thus ends the Bhaktivedanta purport of the First Chapter of Kṛṣṇa, "Title""
if "Thus ends" in text and "Chapter of" in text and "Kṛṣṇa" in text:
# Extract chapter ordinal and title
m = re.search(
r"(?:the\s+)?(\w+)\s+Chapter\s+of\s+Kṛṣṇa,\s*[\"\"\"]*(.+?)[\"\"\"]*\.?\s*$",
text,
)
if m:
# We don't need the ordinal — just the title for cross-referencing
title_from_end = m.group(2).strip().rstrip(".")
# Store by the chapter count we've seen so far (rough approximation)
# Extract from p13 bookmarks
for p in soup.find_all("p"):
cls = get_css_class(p)
if cls != "p13":
continue
text = decode_text(p)
# Match: KB 1970-V-C / Title
m = re.match(r"KB\s+1970-(\d+)-(\d+)\s*/\s*(.+)", text)
if not m:
continue
volume = int(m.group(1))
chapter_in_vol = int(m.group(2))
title = m.group(3).strip()
chapter_num += 1
chapters.append(
{
"number": chapter_num,
"volume": volume,
"chapterInVolume": chapter_in_vol,
"title": title,
}
)
return chapters
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_path = os.path.join(base_dir, "lib", "jsons", "books", "kb", "intro.json")
if not os.path.exists(html_path):
print(f"ERROR: {html_path} not found!")
sys.exit(1)
print(f"Reading {html_path}...")
with open(html_path, "r", encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
# Extract introduction
print("Extracting introduction...")
intro_paragraphs = extract_introduction(soup)
print(f" Found {len(intro_paragraphs)} introduction paragraphs")
# Extract chapter listing
print("Extracting chapter listing...")
chapters = extract_chapters(soup)
print(f" Found {len(chapters)} chapters")
# Count per volume
vol1 = [c for c in chapters if c["volume"] == 1]
vol2 = [c for c in chapters if c["volume"] == 2]
print(f" Volume 1: {len(vol1)} chapters")
print(f" Volume 2: {len(vol2)} chapters")
# Build intro.json
# Strip volume/chapterInVolume from the chapter entries for the public JSON
chapters_public = [{"number": c["number"], "title": c["title"]} for c in chapters]
intro = {
"book": "kb",
"title": "Kṛṣṇa, The Supreme Personality of Godhead",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"description": "A summary study of the Tenth Canto of Śrīmad-Bhāgavatam. Originally published in two volumes in 1970.",
"totalChapters": len(chapters),
"volumes": [
{"number": 1, "totalChapters": len(vol1)},
{"number": 2, "totalChapters": len(vol2)},
],
"chapters": chapters_public,
"introduction": intro_paragraphs,
}
# Write
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(intro, f, ensure_ascii=False, indent=2)
print(f"\nWritten: {output_path}")
# Summary
print(f"\n--- Summary ---")
print(f"Title: {intro['title']}")
print(f"Chapters: {intro['totalChapters']}")
print(f"Introduction paragraphs: {len(intro_paragraphs)}")
# Show first few intro paragraphs
print(f"\n--- Introduction (first 3 paragraphs) ---")
for i, para in enumerate(intro_paragraphs[:3]):
print(f" [{i + 1}] {para[:120]}...")
# Show first and last chapters
print(f"\n--- Chapters ---")
for c in chapters[:5]:
print(f" Ch {c['number']:>2} (Vol {c['volume']}): {c['title']}")
print(f" ...")
for c in chapters[-3:]:
print(f" Ch {c['number']:>2} (Vol {c['volume']}): {c['title']}")
if __name__ == "__main__":
main()
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""Extract Kṛṣṇa Book Volume 1 chapters from other.html.
Volume 1 has 54 chapters (KB 1970-1-1 through KB 1970-1-54).
Each chapter is a prose chapter (no verses) — just paragraphs of purport text.
The chapters are delimited by p13 bookmark tags: "KB 1970-1-N / Title"
Each chapter starts with a p27 heading: "N /Title"
Body paragraphs are in p6 (primary) and p28 classes.
Chapter end markers: "Thus ends the Bhaktivedanta purport of the Nth Chapter..."
Output schema (one JSON per chapter):
{
"book": "kb",
"volume": 1,
"chapter": 1,
"title": "Advent of Lord Kṛṣṇa",
"type": "chapter",
"paragraphs": ["<p>...</p>", ...]
}
Output: lib/jsons/books/kb/vol-1/chapters/01.json through 54.json
Usage:
python3 scripts/kb_extract_vol1_chapters.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner)
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
return decode_scagoudy(tag.get_text().strip())
def get_css_class(tag: Tag) -> str:
classes = tag.get("class", [])
return classes[0] if classes else ""
# ---------------------------------------------------------------------------
# CSS classes for Sanskrit verse pādas (center-aligned italic blocks
# that sometimes appear within chapter prose — e.g., quoted verses)
# ---------------------------------------------------------------------------
VERSE_PADA_CLASSES = frozenset(["p4", "p5", "p10", "p19", "p24"])
# Primary body paragraph classes
BODY_CLASSES = frozenset(["p6", "p28", "p23"])
# Chapter heading class (bold title like "1 /Advent of Lord Kṛṣṇa")
HEADING_CLASS = "p27"
# Bookmark class
BOOKMARK_CLASS = "p13"
# Volume header class
VOLUME_HEADER_CLASS = "p12"
# Classes to skip entirely
SKIP_CLASSES = frozenset(
[
"p9", # empty / decorative breaks
"p11", # empty breaks
"p15", # empty breaks
"p17", # empty breaks
"p20", # empty breaks
"p14", # section labels like "Flap:"
"p16", # "Not for sale in India"
"p25", # handwritten notes marker
"p26", # Apple Corps header
"p29", # image descriptions (color plate references)
"p30", # image descriptions
]
)
def is_empty(tag: Tag) -> bool:
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def extract_vol1_chapters(soup: BeautifulSoup):
"""Extract all 54 chapters of Volume 1.
Returns a list of chapter dicts.
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find all Vol 1 chapter bookmark positions
# Format: "KB 1970-1-N / Title"
chapter_bookmarks = [] # (paragraph_index, chapter_num, title)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOKMARK_CLASS:
continue
text = decode_text(p)
m = re.match(r"KB\s+1970-1-(\d+)\s*/\s*(.+)", text)
if m:
ch_num = int(m.group(1))
title = m.group(2).strip()
chapter_bookmarks.append((i, ch_num, title))
if not chapter_bookmarks:
print("ERROR: No Vol 1 chapter bookmarks found!")
return []
print(f" Found {len(chapter_bookmarks)} Vol 1 chapter bookmarks")
# Step 2: Find the end boundary (Volume 2 header or end of file)
vol2_start = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls == VOLUME_HEADER_CLASS:
text = decode_text(p)
if "Volume 2" in text:
vol2_start = i
break
# Step 3: Extract each chapter
chapters = []
for idx, (bookmark_pos, ch_num, title) in enumerate(chapter_bookmarks):
# Chapter content starts after the bookmark
content_start = bookmark_pos + 1
# Chapter content ends at the next chapter's bookmark (or Vol 2 boundary)
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = vol2_start
# Extract paragraphs for this chapter
paragraphs = extract_chapter_paragraphs(
all_paragraphs, content_start, content_end
)
chapters.append(
{
"book": "kb",
"volume": 1,
"chapter": ch_num,
"title": title,
"type": "chapter",
"paragraphs": paragraphs,
}
)
return chapters
def extract_chapter_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list[str]:
"""Extract the prose paragraphs for a single chapter.
Handles:
- Regular body paragraphs (p6, p28)
- Quoted Sanskrit verse blocks (p4, p5, p19, p24) → grouped into centered italic
- Chapter heading (p27) → included as first paragraph
- Chapter end marker ("Thus ends...") → included as last paragraph (italic)
- Skips empty/decorative paragraphs
"""
result: list[str] = []
verse_accumulator: list[str] = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags that might be inside the range
if cls == BOOKMARK_CLASS:
continue
# Skip volume headers
if cls == VOLUME_HEADER_CLASS:
continue
# Sanskrit verse pādas — accumulate into centered block
# Use decode_html_content to preserve <br> tags within multi-line verse elements
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
verse_accumulator.append(html)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — keep it, it's the chapter title
if cls == HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs and everything else
html = decode_html_content(p)
if html:
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_dir = os.path.join(
base_dir, "lib", "jsons", "books", "kb", "vol-1", "chapters"
)
if not os.path.exists(html_path):
print(f"ERROR: {html_path} not found!")
sys.exit(1)
print(f"Reading {html_path}...")
with open(html_path, "r", encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
print("Extracting Volume 1 chapters...")
chapters = extract_vol1_chapters(soup)
if not chapters:
print("ERROR: No chapters extracted!")
sys.exit(1)
print(f"\nExtracted {len(chapters)} chapters\n")
# Write each chapter
for ch in chapters:
ch_num = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_dir, f"{ch_num}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — {ch['title']}"
)
# Summary
total_paragraphs = sum(len(ch["paragraphs"]) for ch in chapters)
print(f"\n--- Summary ---")
print(f"Total chapters: {len(chapters)}")
print(f"Total paragraphs: {total_paragraphs}")
print(f"Average paragraphs per chapter: {total_paragraphs / len(chapters):.1f}")
print(f"Output: {output_dir}/")
# Show samples from first and last chapter
print(f"\n--- Sample: Chapter 1 ---")
ch1 = chapters[0]
for i, p in enumerate(ch1["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
print(f"\n--- Sample: Chapter {chapters[-1]['chapter']} ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# Quality check: verify all chapters have the end marker
print(f"\n--- Quality Check ---")
missing_end = 0
for ch in chapters:
has_end = any("Thus end" in p for p in ch["paragraphs"])
if not has_end:
missing_end += 1
print(
f" ⚠ Chapter {ch['chapter']} ({ch['title']}) — no 'Thus ends' marker"
)
if missing_end == 0:
print(f" ✅ All {len(chapters)} chapters have 'Thus ends' markers")
else:
print(f" ⚠ {missing_end} chapters missing 'Thus ends' markers")
if __name__ == "__main__":
main()
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Extract Kṛṣṇa Book Volume 1 introductory segments from other.html.
The Volume 1 front matter (lines ~772–901) contains 5 segments before Chapter 1:
1. Flap intro — book jacket text
2. Dedication — "To My Father, Gour Mohon De (1849-1930)"
3. Words from Apple — George Harrison's introduction
4. Preface — Prabhupāda's preface
5. Introduction — Prabhupāda's introduction
Each segment is extracted into a JSON file with a common schema:
{
"slug": "flap-intro",
"title": "Flap Introduction",
"type": "intro-segment",
"book": "kb",
"volume": 1,
"order": 1,
"paragraphs": ["<p>...</p>", ...]
}
Output: lib/jsons/books/kb/vol-1/intro/*.json
Usage:
python3 scripts/kb_extract_vol1_intro.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
inner = re.sub(r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner)
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
return decode_scagoudy(tag.get_text().strip())
def get_css_class(tag: Tag) -> str:
classes = tag.get("class", [])
return classes[0] if classes else ""
# CSS classes for Sanskrit verse pādas (center-aligned italic)
VERSE_PADA_CLASSES = frozenset(["p4", "p5", "p10", "p19", "p24"])
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty / whitespace only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# Segment definitions
# ---------------------------------------------------------------------------
SEGMENTS = [
{
"slug": "flap-intro",
"title": "Flap Introduction",
"order": 1,
"bookmark": "Kb 1970 /Flap intro",
},
{
"slug": "dedication",
"title": "Dedication — To My Father, Gour Mohon De (1849–1930)",
"order": 2,
"bookmark": "Kb 1970 / To My Father, Gour Mohon De (1849-1930)",
},
{
"slug": "words-from-apple",
"title": "Words from Apple — by George Harrison",
"order": 3,
"bookmark": "Kb 1970 / Words from Apple",
},
{
"slug": "preface",
"title": "Preface",
"order": 4,
"bookmark": "Kb 1970 / Preface",
},
{
"slug": "introduction",
"title": "Introduction",
"order": 5,
"bookmark": "Kb 1970 / Introduction",
},
]
# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def find_segment_ranges(soup: BeautifulSoup):
"""Find the start/end paragraph indices for each segment.
Segments are delimited by p13 bookmark tags. The first chapter marker
(KB 1970-1-1) ends the last intro segment.
"""
all_paragraphs = list(soup.find_all("p"))
# Find all p13 bookmark indices within the KB volume 1 area
bookmark_positions = [] # (index, text)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls == "p13":
text = decode_text(p)
bookmark_positions.append((i, text))
# Map each segment to its start/end paragraph indices
ranges = {}
for seg in SEGMENTS:
# Find the bookmark that starts this segment
start_idx = None
for i, (pos, text) in enumerate(bookmark_positions):
if seg["bookmark"] in text:
# Content starts AFTER the bookmark tag
start_idx = pos + 1
# End is the next bookmark
if i + 1 < len(bookmark_positions):
end_idx = bookmark_positions[i + 1][0]
else:
end_idx = len(all_paragraphs)
ranges[seg["slug"]] = (start_idx, end_idx, all_paragraphs)
break
if start_idx is None:
print(
f" WARNING: Could not find bookmark for '{seg['slug']}': '{seg['bookmark']}'"
)
return ranges
def extract_segment_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list[str]:
"""Extract paragraphs for a single segment, handling verse grouping."""
result: list[str] = []
verse_accumulator: list[str] = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
text = p.get_text().strip()
# Skip the segment's own heading (p12, p14, p22, p27 bold headers)
# These are the display titles like "Flap:", "Words From Apple", "Preface", etc.
# We keep them as the first paragraph if they have meaningful content
if cls in ("p12", "p14"):
# Volume header or section label — skip
continue
# Skip empty paragraphs
if is_empty(p):
continue
# Skip bookmark tags (p13) that might be nested
if cls == "p13":
continue
# Sanskrit verse pādas — accumulate
# Use decode_html_content to preserve <br> tags within multi-line verse elements
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
verse_accumulator.append(html)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Extract the HTML content
html = decode_html_content(p)
if html:
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f" Written: {filepath}")
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_dir = os.path.join(base_dir, "lib", "jsons", "books", "kb", "vol-1", "intro")
if not os.path.exists(html_path):
print(f"ERROR: {html_path} not found!")
sys.exit(1)
print(f"Reading {html_path}...")
with open(html_path, "r", encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
print("Finding segment boundaries...")
ranges = find_segment_ranges(soup)
print(f"Extracting {len(SEGMENTS)} introductory segments...\n")
for seg in SEGMENTS:
slug = seg["slug"]
if slug not in ranges:
print(f" ⏭ Skipping '{slug}' — bookmark not found")
continue
start_idx, end_idx, all_paragraphs = ranges[slug]
paragraphs = extract_segment_paragraphs(all_paragraphs, start_idx, end_idx)
doc = {
"slug": slug,
"title": seg["title"],
"type": "intro-segment",
"book": "kb",
"volume": 1,
"order": seg["order"],
"paragraphs": paragraphs,
}
filepath = os.path.join(output_dir, f"{slug}.json")
write_json(doc, filepath)
print(f" {seg['title']}: {len(paragraphs)} paragraphs")
# Summary
print(f"\n--- Summary ---")
for seg in SEGMENTS:
slug = seg["slug"]
filepath = os.path.join(output_dir, f"{slug}.json")
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
doc = json.load(f)
# Show first paragraph preview
first = doc["paragraphs"][0] if doc["paragraphs"] else "(empty)"
# Strip HTML tags for preview
preview = re.sub(r"<[^>]+>", "", first)[:80]
print(f" {seg['order']}. {seg['title']}")
print(f' {len(doc["paragraphs"])} paragraphs — "{preview}..."')
else:
print(f" {seg['order']}. {seg['title']} — NOT EXTRACTED")
if __name__ == "__main__":
main()
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""Extract Kṛṣṇa Book Volume 2 chapters from other.html.
Volume 2 has 35 chapters (KB 1970-2-1 through KB 1970-2-35).
Each chapter is a prose chapter (no verses) — just paragraphs of purport text.
The chapters are delimited by p13 bookmark tags: "KB 1970-2-N / Title"
Each chapter starts with a p27 heading: "N /Title"
Body paragraphs are in p6 (primary) and p28 classes.
Chapter end markers: "Thus ends the Bhaktivedanta purport of the Nth Chapter..."
Output schema (one JSON per chapter):
{
"book": "kb",
"volume": 2,
"chapter": 1,
"title": "The Story of the Syamantaka Jewel",
"type": "chapter",
"paragraphs": ["<p>...</p>", ...]
}
Output: lib/jsons/books/kb/vol-2/chapters/01.json through 35.json
Usage:
python3 scripts/kb_extract_vol2_chapters.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner)
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
return decode_scagoudy(tag.get_text().strip())
def get_css_class(tag: Tag) -> str:
classes = tag.get("class", [])
return classes[0] if classes else ""
# ---------------------------------------------------------------------------
# CSS classes for Sanskrit verse pādas (center-aligned italic blocks
# that sometimes appear within chapter prose — e.g., quoted verses)
# ---------------------------------------------------------------------------
VERSE_PADA_CLASSES = frozenset(["p4", "p5", "p10", "p19", "p24"])
# Primary body paragraph classes
BODY_CLASSES = frozenset(["p6", "p28", "p23"])
# Chapter heading class (bold title like "1 /The Story of the Syamantaka Jewel")
HEADING_CLASS = "p27"
# Bookmark class
BOOKMARK_CLASS = "p13"
# Volume header class
VOLUME_HEADER_CLASS = "p12"
# Classes to skip entirely
SKIP_CLASSES = frozenset(
[
"p9", # empty / decorative breaks
"p11", # empty breaks
"p15", # empty breaks
"p17", # empty breaks
"p20", # empty breaks
"p14", # section labels
"p16", # "Not for sale in India"
"p25", # handwritten notes marker
"p26", # Apple Corps header
"p29", # image descriptions (color plate references)
"p30", # image descriptions
]
)
def is_empty(tag: Tag) -> bool:
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def extract_vol2_chapters(soup: BeautifulSoup):
"""Extract all 35 chapters of Volume 2.
Returns a list of chapter dicts.
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find all Vol 2 chapter bookmark positions
# Format: "KB 1970-2-N / Title"
chapter_bookmarks = [] # (paragraph_index, chapter_num, title)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOKMARK_CLASS:
continue
text = decode_text(p)
m = re.match(r"KB\s+1970-2-(\d+)\s*/\s*(.+)", text)
if m:
ch_num = int(m.group(1))
title = m.group(2).strip()
chapter_bookmarks.append((i, ch_num, title))
if not chapter_bookmarks:
print("ERROR: No Vol 2 chapter bookmarks found!")
return []
print(f" Found {len(chapter_bookmarks)} Vol 2 chapter bookmarks")
# Step 2: Find the end boundary — the next book section or end of file
# Look for the next p12 volume header that is NOT KB Vol 2
vol2_end = len(all_paragraphs)
# Start searching after the last Vol 2 chapter bookmark
last_bookmark_pos = chapter_bookmarks[-1][0]
for i in range(last_bookmark_pos + 1, len(all_paragraphs)):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == VOLUME_HEADER_CLASS:
text = decode_text(p)
# If it's a different book header (not KB), that's our end
if "Volume 2" not in text:
vol2_end = i
break
# Step 3: Extract each chapter
chapters = []
for idx, (bookmark_pos, ch_num, title) in enumerate(chapter_bookmarks):
# Chapter content starts after the bookmark
content_start = bookmark_pos + 1
# Chapter content ends at the next chapter's bookmark (or end boundary)
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = vol2_end
# Extract paragraphs for this chapter
paragraphs = extract_chapter_paragraphs(
all_paragraphs, content_start, content_end
)
chapters.append(
{
"book": "kb",
"volume": 2,
"chapter": ch_num,
"title": title,
"type": "chapter",
"paragraphs": paragraphs,
}
)
return chapters
def extract_chapter_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list[str]:
"""Extract the prose paragraphs for a single chapter.
Handles:
- Regular body paragraphs (p6, p28)
- Quoted Sanskrit verse blocks (p4, p5, p19, p24) → grouped into centered italic
- Chapter heading (p27) → included as first paragraph
- Chapter end marker ("Thus ends...") → included as last paragraph (italic)
- Skips empty/decorative paragraphs
"""
result: list[str] = []
verse_accumulator: list[str] = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags that might be inside the range
if cls == BOOKMARK_CLASS:
continue
# Skip volume headers
if cls == VOLUME_HEADER_CLASS:
continue
# Sanskrit verse pādas — accumulate into centered block
# Use decode_html_content to preserve <br> tags within multi-line verse elements
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
verse_accumulator.append(html)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — keep it, it's the chapter title
if cls == HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs and everything else
html = decode_html_content(p)
if html:
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_dir = os.path.join(
base_dir, "lib", "jsons", "books", "kb", "vol-2", "chapters"
)
if not os.path.exists(html_path):
print(f"ERROR: {html_path} not found!")
sys.exit(1)
print(f"Reading {html_path}...")
with open(html_path, "r", encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
print("Extracting Volume 2 chapters...")
chapters = extract_vol2_chapters(soup)
if not chapters:
print("ERROR: No chapters extracted!")
sys.exit(1)
print(f"\nExtracted {len(chapters)} chapters\n")
# Write each chapter
for ch in chapters:
ch_num = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_dir, f"{ch_num}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — {ch['title']}"
)
# Summary
total_paragraphs = sum(len(ch["paragraphs"]) for ch in chapters)
print(f"\n--- Summary ---")
print(f"Total chapters: {len(chapters)}")
print(f"Total paragraphs: {total_paragraphs}")
print(f"Average paragraphs per chapter: {total_paragraphs / len(chapters):.1f}")
print(f"Output: {output_dir}/")
# Show samples from first and last chapter
print(f"\n--- Sample: Chapter 1 ---")
ch1 = chapters[0]
for i, p in enumerate(ch1["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
print(f"\n--- Sample: Chapter {chapters[-1]['chapter']} ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# Quality check: verify all chapters have the end marker
print(f"\n--- Quality Check ---")
missing_end = 0
for ch in chapters:
has_end = any("Thus end" in p for p in ch["paragraphs"])
if not has_end:
missing_end += 1
print(
f" ⚠ Chapter {ch['chapter']} ({ch['title']}) — no 'Thus ends' marker"
)
if missing_end == 0:
print(f" ✅ All {len(chapters)} chapters have 'Thus ends' markers")
else:
print(f" ⚠ {missing_end} chapters missing 'Thus ends' markers")
if __name__ == "__main__":
main()
+191 -27
View File
@@ -49,6 +49,26 @@ function readAllJsonInDir(dir: string): unknown[] {
.map((f) => readJson(path.join(dir, f))); .map((f) => readJson(path.join(dir, f)));
} }
/**
* Insert documents one at a time with a small delay every N docs.
*
* Uses insertOne to avoid BSON wire-protocol corruption that occurs
* with insertMany on certain MongoDB server/proxy configurations.
* A brief pause every 100 docs prevents connection overload.
*/
async function insertBatched(
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
docs: Record<string, unknown>[],
) {
for (let i = 0; i < docs.length; i++) {
await col.insertOne(docs[i]);
// Pause briefly every 100 docs to give the connection breathing room
if ((i + 1) % 100 === 0) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -95,6 +115,17 @@ type CcVerse = {
purport: string[]; purport: string[];
}; };
type ProseChapterJson = {
book: string;
volume: number;
chapter?: number;
title: string;
type: string;
slug?: string;
order?: number;
paragraphs: string[];
};
type Sb1962Verse = { type Sb1962Verse = {
book_title: string; book_title: string;
chapter: number; chapter: number;
@@ -178,9 +209,7 @@ async function seedBg(db: ReturnType<MongoClient["db"]>) {
} }
} }
if (docs.length > 0) { await insertBatched(versesCol, docs);
await versesCol.insertMany(docs, { ordered: false });
}
console.log(` ✅ BG: ${docs.length} verses`); console.log(` ✅ BG: ${docs.length} verses`);
} }
@@ -227,14 +256,14 @@ async function seedSb(db: ReturnType<MongoClient["db"]>) {
.filter((d) => fs.statSync(path.join(chaptersDir, d)).isDirectory()) .filter((d) => fs.statSync(path.join(chaptersDir, d)).isDirectory())
.sort(); .sort();
const docs: Record<string, unknown>[] = []; const cantoDocs: Record<string, unknown>[] = [];
for (const chDir of chapterDirs) { for (const chDir of chapterDirs) {
const versesDir = path.join(chaptersDir, chDir, "verses"); const versesDir = path.join(chaptersDir, chDir, "verses");
const verses = readAllJsonInDir(versesDir) as SbVerse[]; const verses = readAllJsonInDir(versesDir) as SbVerse[];
for (const v of verses) { for (const v of verses) {
docs.push({ cantoDocs.push({
book: "sb", book: "sb",
division: String(v.canto), division: String(v.canto),
chapter: v.chapter, chapter: v.chapter,
@@ -252,11 +281,9 @@ async function seedSb(db: ReturnType<MongoClient["db"]>) {
} }
} }
if (docs.length > 0) { await insertBatched(versesCol, cantoDocs);
await versesCol.insertMany(docs, { ordered: false }); totalCount += cantoDocs.length;
} console.log(` Canto ${cantoDir}: ${cantoDocs.length} verses`);
totalCount += docs.length;
console.log(` Canto ${cantoDir}: ${docs.length} verses`);
} }
console.log(` ✅ SB: ${totalCount} verses`); console.log(` ✅ SB: ${totalCount} verses`);
@@ -305,14 +332,14 @@ async function seedCc(db: ReturnType<MongoClient["db"]>) {
.filter((d) => fs.statSync(path.join(chaptersDir, d)).isDirectory()) .filter((d) => fs.statSync(path.join(chaptersDir, d)).isDirectory())
.sort(); .sort();
const docs: Record<string, unknown>[] = []; const lilaDocs: Record<string, unknown>[] = [];
for (const chDir of chapterDirs) { for (const chDir of chapterDirs) {
const versesDir = path.join(chaptersDir, chDir, "verses"); const versesDir = path.join(chaptersDir, chDir, "verses");
const verses = readAllJsonInDir(versesDir) as CcVerse[]; const verses = readAllJsonInDir(versesDir) as CcVerse[];
for (const v of verses) { for (const v of verses) {
docs.push({ lilaDocs.push({
book: "cc", book: "cc",
division: v.lila, division: v.lila,
chapter: v.chapter, chapter: v.chapter,
@@ -330,11 +357,9 @@ async function seedCc(db: ReturnType<MongoClient["db"]>) {
} }
} }
if (docs.length > 0) { await insertBatched(versesCol, lilaDocs);
await versesCol.insertMany(docs, { ordered: false }); totalCount += lilaDocs.length;
} console.log(` Līlā ${lilaDir}: ${lilaDocs.length} verses`);
totalCount += docs.length;
console.log(` Līlā ${lilaDir}: ${docs.length} verses`);
} }
console.log(` ✅ CC: ${totalCount} verses`); console.log(` ✅ CC: ${totalCount} verses`);
@@ -397,12 +422,143 @@ async function seedSb1962(db: ReturnType<MongoClient["db"]>) {
} }
} }
if (docs.length > 0) { await insertBatched(versesCol, docs);
await versesCol.insertMany(docs, { ordered: false });
}
console.log(` ✅ SB 1962: ${docs.length} verses`); console.log(` ✅ SB 1962: ${docs.length} verses`);
} }
// ---------------------------------------------------------------------------
// Kṛṣṇa Book
// ---------------------------------------------------------------------------
async function seedKb(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(LIB_DIR, "books", "kb", "intro.json");
if (!fs.existsSync(introPath)) {
console.log(" ⏭ KB intro.json not found, skipping");
return;
}
const intro = readJson(introPath);
// Book meta
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "kb" });
await metaCol.insertOne({ book: "kb", data: intro });
console.log(" 📖 KB book meta inserted");
// Prose chapters collection
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "kb" });
const docs: Record<string, unknown>[] = [];
// Volume 1 intro segments
const vol1IntroDir = path.join(LIB_DIR, "books", "kb", "vol-1", "intro");
if (fs.existsSync(vol1IntroDir)) {
const introFiles = fs
.readdirSync(vol1IntroDir)
.filter((f) => f.endsWith(".json"))
.sort();
const introDocsV1: Record<string, unknown>[] = [];
for (const file of introFiles) {
const seg = readJson<ProseChapterJson>(path.join(vol1IntroDir, file));
introDocsV1.push({
book: "kb",
volume: seg.volume ?? 1,
chapter: seg.chapter ?? null,
title: seg.title,
type: seg.type || "intro-segment",
slug: seg.slug || file.replace(".json", ""),
order: seg.order ?? 0,
paragraphs: seg.paragraphs,
});
}
await insertBatched(proseCol, introDocsV1);
console.log(` Vol 1 intro segments: ${introFiles.length}`);
}
// Volume 1 chapters
const vol1ChapDir = path.join(LIB_DIR, "books", "kb", "vol-1", "chapters");
if (fs.existsSync(vol1ChapDir)) {
const chapterFiles = fs
.readdirSync(vol1ChapDir)
.filter((f) => f.endsWith(".json"))
.sort();
const chapDocsV1: Record<string, unknown>[] = [];
for (const file of chapterFiles) {
const ch = readJson<ProseChapterJson>(path.join(vol1ChapDir, file));
chapDocsV1.push({
book: "kb",
volume: ch.volume ?? 1,
chapter: ch.chapter ?? null,
title: ch.title,
type: ch.type || "chapter",
slug: String(ch.chapter ?? file.replace(".json", "")).padStart(2, "0"),
order: 100 + (ch.chapter ?? 0),
paragraphs: ch.paragraphs,
});
}
await insertBatched(proseCol, chapDocsV1);
console.log(` Vol 1 chapters: ${chapterFiles.length}`);
}
// Volume 2 intro segments
const vol2IntroDir = path.join(LIB_DIR, "books", "kb", "vol-2", "intro");
if (fs.existsSync(vol2IntroDir)) {
const introFiles = fs
.readdirSync(vol2IntroDir)
.filter((f) => f.endsWith(".json"))
.sort();
const introDocsV2: Record<string, unknown>[] = [];
for (const file of introFiles) {
const seg = readJson<ProseChapterJson>(path.join(vol2IntroDir, file));
introDocsV2.push({
book: "kb",
volume: seg.volume ?? 2,
chapter: seg.chapter ?? null,
title: seg.title,
type: seg.type || "intro-segment",
slug: seg.slug || file.replace(".json", ""),
order: seg.order ?? 0,
paragraphs: seg.paragraphs,
});
}
await insertBatched(proseCol, introDocsV2);
console.log(` Vol 2 intro segments: ${introFiles.length}`);
}
// Volume 2 chapters
const vol2ChapDir = path.join(LIB_DIR, "books", "kb", "vol-2", "chapters");
if (fs.existsSync(vol2ChapDir)) {
const chapterFiles = fs
.readdirSync(vol2ChapDir)
.filter((f) => f.endsWith(".json"))
.sort();
const chapDocsV2: Record<string, unknown>[] = [];
for (const file of chapterFiles) {
const ch = readJson<ProseChapterJson>(path.join(vol2ChapDir, file));
chapDocsV2.push({
book: "kb",
volume: ch.volume ?? 2,
chapter: ch.chapter ?? null,
title: ch.title,
type: ch.type || "chapter",
slug: String(ch.chapter ?? file.replace(".json", "")).padStart(2, "0"),
order: 100 + (ch.chapter ?? 0),
paragraphs: ch.paragraphs,
});
}
await insertBatched(proseCol, chapDocsV2);
console.log(` Vol 2 chapters: ${chapterFiles.length}`);
}
const totalDocs = await proseCol.countDocuments({ book: "kb" });
console.log(` ✅ KB: ${totalDocs} prose entries`);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Glossary // Glossary
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -419,16 +575,14 @@ async function seedGlossary(db: ReturnType<MongoClient["db"]>) {
const col = db.collection("glossary"); const col = db.collection("glossary");
await col.deleteMany({}); await col.deleteMany({});
const docs = data.entries.map((entry) => ({ const glossaryDocs = data.entries.map((entry) => ({
term: entry.term, term: entry.term,
definition: entry.definition, definition: entry.definition,
definitionHtml: entry.definition_html, definitionHtml: entry.definition_html,
})); }));
if (docs.length > 0) { await insertBatched(col, glossaryDocs);
await col.insertMany(docs, { ordered: false }); console.log(` ✅ Glossary: ${glossaryDocs.length} entries`);
}
console.log(` ✅ Glossary: ${docs.length} entries`);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -453,6 +607,12 @@ async function createIndexes(db: ReturnType<MongoClient["db"]>) {
const glossaryCol = db.collection("glossary"); const glossaryCol = db.collection("glossary");
await glossaryCol.createIndex({ term: 1 }, { unique: true }); await glossaryCol.createIndex({ term: 1 }, { unique: true });
const proseCol = db.collection("prose_chapters");
await proseCol.createIndex({ book: 1 });
await proseCol.createIndex({ book: 1, volume: 1 });
await proseCol.createIndex({ book: 1, volume: 1, slug: 1 }, { unique: true });
await proseCol.createIndex({ book: 1, volume: 1, type: 1 });
console.log(" ✅ Indexes created"); console.log(" ✅ Indexes created");
} }
@@ -487,11 +647,15 @@ async function main() {
await seedSb1962(db); await seedSb1962(db);
console.log(); console.log();
console.log("[5/6] Glossary"); console.log("[5/7] Kṛṣṇa Book");
await seedKb(db);
console.log();
console.log("[6/7] Glossary");
await seedGlossary(db); await seedGlossary(db);
console.log(); console.log();
console.log("[6/6] Indexes"); console.log("[7/7] Indexes");
await createIndexes(db); await createIndexes(db);
console.log(); console.log();
+149
View File
@@ -0,0 +1,149 @@
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env" });
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("❌ DATABASE_URL is not set");
process.exit(1);
}
const LIB_DIR = path.join(process.cwd(), "lib", "jsons");
async function main() {
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB");
const db = client.db();
const col = db.collection("prose_chapters");
// Delete existing Vol 2 data
const deleted = await col.deleteMany({ book: "kb", volume: 2 });
console.log(`🗑 Cleared ${deleted.deletedCount} existing Vol 2 docs`);
const chapDir = path.join(LIB_DIR, "books", "kb", "vol-2", "chapters");
if (!fs.existsSync(chapDir)) {
console.error(`ERROR: ${chapDir} not found!`);
await client.close();
process.exit(1);
}
const files = fs
.readdirSync(chapDir)
.filter((f) => f.endsWith(".json"))
.sort();
console.log(`\n📝 Seeding ${files.length} Vol 2 chapters...\n`);
let success = 0;
let failed = 0;
for (const file of files) {
const data = JSON.parse(fs.readFileSync(path.join(chapDir, file), "utf-8"));
const slug = String(data.chapter).padStart(2, "0");
const filter = { book: "kb" as const, volume: 2, slug };
// Sanitize title: encode non-ASCII chars as HTML entities to avoid
// BSON wire-protocol corruption on the initial insertOne
const safeTitle = (data.title as string).replace(
/[^\x20-\x7E]/g,
(ch: string) => `&#${ch.codePointAt(0)};`,
);
try {
// Step 1: Insert empty document with ASCII-safe title (small, won't corrupt)
await col.insertOne({
book: "kb",
volume: 2,
chapter: data.chapter ?? null,
title: safeTitle,
type: "chapter",
slug,
order: 100 + (data.chapter ?? 0),
paragraphs: [] as string[],
});
// Step 1b: Restore the real Unicode title via $set update
// (small update, less likely to corrupt than the initial insert)
try {
await col.updateOne(filter, { $set: { title: data.title } });
} catch {
// If title update fails, keep the HTML-entity version — it's readable
}
// Step 2: Push paragraphs one at a time via $push
// Each update is tiny so it avoids BSON wire corruption.
// If a paragraph fails (BSON encoding error), retry with ASCII-safe version.
for (let i = 0; i < data.paragraphs.length; i++) {
const para: string = data.paragraphs[i];
try {
await col.updateOne(filter, { $push: { paragraphs: para } });
} catch {
// Fallback: strip non-ASCII characters that may cause wire corruption
const safePara = para.replace(
/[^\x20-\x7E\n\r\t<>/='"&;.\-!?:,()[\]{}#@*+%$^~`|\\]/g,
function (ch) {
// Keep common IAST diacritical characters by encoding as HTML entities
const cp = ch.codePointAt(0);
if (cp !== undefined) return `&#${cp};`;
return "";
},
);
try {
await col.updateOne(filter, { $push: { paragraphs: safePara } });
} catch {
// Last resort: push a placeholder
await col.updateOne(filter, {
$push: {
paragraphs: `[Paragraph ${i + 1} could not be stored due to encoding issues]`,
},
});
console.warn(
` ⚠ Ch ${data.chapter} para ${i + 1}: encoding fallback`,
);
}
}
}
// Step 3: Verify round-trip
const readBack = await col.findOne(filter);
if (
readBack &&
Array.isArray(readBack.paragraphs) &&
readBack.paragraphs.length === data.paragraphs.length
) {
success++;
console.log(
` ✓ Ch ${data.chapter}: ${data.paragraphs.length} paragraphs — ${data.title}`,
);
} else {
failed++;
console.log(
` ✗ Ch ${data.chapter}: verification failed (expected ${data.paragraphs.length}, got ${readBack?.paragraphs?.length ?? "null"})`,
);
}
} catch (err: unknown) {
failed++;
const msg = err instanceof Error ? err.message : String(err);
console.log(` ✗ Ch ${data.chapter}: ${msg.substring(0, 100)}`);
}
}
// Final count
const total = await col.countDocuments({ book: "kb" });
console.log(`\n--- Results ---`);
console.log(`Success: ${success} | Failed: ${failed}`);
console.log(`Total KB prose docs in DB: ${total}`);
await client.close();
}
main().catch((e) => {
console.error("❌ FATAL:", e.message);
process.exit(1);
});
+6 -6
View File
@@ -15,12 +15,12 @@
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next",
} },
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"],
} },
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
@@ -28,7 +28,7 @@
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts",
], ],
"exclude": ["node_modules"] "exclude": ["node_modules", "scripts"],
} }
+14 -1
View File
@@ -2640,7 +2640,7 @@ internal-slot@^1.1.0:
hasown "^2.0.2" hasown "^2.0.2"
side-channel "^1.1.0" side-channel "^1.1.0"
ip-address@10.1.0: ip-address@^10.0.1, ip-address@10.1.0:
version "10.1.0" version "10.1.0"
resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz" resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz"
integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==
@@ -4185,6 +4185,19 @@ sisteransi@^1.0.5:
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
smart-buffer@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
socks@^2.8.6:
version "2.8.7"
resolved "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz"
integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==
dependencies:
ip-address "^10.0.1"
smart-buffer "^4.2.0"
source-map-js@^1.0.2, source-map-js@^1.2.1: source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"