feat: add journals

This commit is contained in:
gosangam
2026-05-03 21:02:41 +05:30
parent 89106efc98
commit 35d1d14f37
47 changed files with 11365 additions and 37 deletions
+53 -6
View File
@@ -251,6 +251,9 @@ model GlossaryEntry {
| SB First Canto (1962 Edition) | `sb1962` | 762 | 19 | — |
| Śrī Īśopaniṣad | `iso` | 19 | — | Invocation + 18 mantras |
| The Nectar of Instruction | `noi` | 11 | — | 11 texts |
| Śrī Brahma-saṁhitā | `bs` | 62 | 1 (Ch. 5) | — |
| Mukunda-mālā-stotra | `mm` | 6 | 1 | — |
| Nārada-bhakti-sūtra | `nbs` | 8 | 1 | — |
### Prose Books (stored in `prose_chapters` collection)
@@ -272,13 +275,40 @@ model GlossaryEntry {
| The Kṛṣṇa Consciousness Handbook | `hkh` | 19 | other.html |
| The Science of Self-Realization | `ssr` | 50 sub-chapters (8 parents) | other.html |
| Dialectic Spiritualism | `ds` | 36 sub-chapters (13 parents) | other.html |
| Life Comes from Life | `lcl` | 16 | other.html |
| Light of the Bhāgavata | `lob` | 48 | other.html |
| Teachings of Lord Kapila | `tlk` | 18 | other.html |
| Teachings of Queen Kuntī | `tqk` | 26 | other.html |
| Gītār Gān | `gg` | 18 | other.html (Bengali poetic BG) |
| Songs of the Vaiṣṇava Ācāryas | `sva` | 67 songs (4 parts, hierarchical) | other.html |
| Message of Godhead | `mog` | 3 | others-before-1977.html |
| Śrī Caitanya-bhāgavata Commentary | `cbhc` | 4 | others-before-1977.html |
| Bhagavāner Kathā | `bk` | 2 | others-before-1977.html (ScaGoudy) |
| Prayers of King Kulaśekhara (1958) | `pk` | 6 | others-before-1977.html |
### Essays (stored in `essays` collection)
| Essay | Slug | Group |
|-------|------|-------|
| Krishna The Reservoir Of Pleasure | `krishna-reservoir-of-pleasure` | `two-essays` |
| Who Is Crazy? | `who-is-crazy` | `two-essays` |
| Essay | Slug | Group | Year |
|-------|------|-------|------|
| Krishna The Reservoir Of Pleasure | `krishna-reservoir-of-pleasure` | `two-essays` | 1966 |
| Who Is Crazy? | `who-is-crazy` | `two-essays` | 1966 |
| The Spiritual Masters | `the-spiritual-masters` | `cc-essays` | 1960 |
| The Absolute Truth | `the-absolute-truth` | `cc-essays` | 1957 |
| The Causes of Lord Caitanya's Incarnation | `causes-of-lord-caitanyas-incarnation` | `cc-essays` | 1957 |
| The Confidential Reasons for Lord Caitanya's Appearance | `confidential-reasons-for-lord-caitanyas-appearance` | `cc-essays` | 1957 |
| The Glories of Lord Nityānanda | `glories-of-lord-nityananda` | `cc-essays` | 1957 |
| Vedānta-sūtra (Typed Transcript) | `vedanta-sutra-typed` | `typed-transcripts` | 1969 |
| Shaktipujah | `shaktipujah` | `reviews` | — |
| Devotee and the Divinity | `devotee-and-the-divinity` | `standalone` | — |
### Other Documents (stored in `book_meta` + `prose_chapters` with `od-` prefix)
| Document | Code | Chapters |
|----------|------|----------|
| An Analysis of Bhagwat Geeta | `od-analysis-bg` | 3 |
| Solution of Present Crisis by Bhagwat Geeta | `od-solution-crisis` | 4 |
| Śrī Caitanya Mahāprabhu | `od-sri-caitanya` | 3 |
| Conception of Geeta Nagari on the Principles of Gandhi | `od-geeta-nagari` | 1 |
### Other
@@ -315,6 +345,11 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
- **TLC (1968 Edition)** — plain English transliterations ("Krishna" not "Kṛṣṇa")
- **Two Essays (1966)** — plain English
- **GG (Gītār Gān)** — Bengali poetic text (ScaGoudy decoded for transliteration)
- **LCL (Life Comes from Life)** — dialogue book, ScaGoudy decoded
- **MOG, CBHC, PK** — pre-1977 handwritten documents, plain English
- **BK (Bhagavāner Kathā)** — pre-1977, uses ScaGoudy encoding
- **CC Essays, Vedānta-sūtra transcript** — plain English
- All other books from other.html use ScaGoudy encoding
---
@@ -331,13 +366,16 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
| `/books/sb1962/[chapter]/[verse]` | SB1962 |
| `/books/iso/[mantra]` | Īśopaniṣad (verse + intro segments) |
| `/books/noi/[text]` | Nectar of Instruction (verse + intro segments) |
| `/books/bs/[chapter]/[verse]` | Brahma-saṁhitā (chapter 5, 62 verses) |
| `/books/mm/[chapter]/[verse]` | Mukunda-mālā-stotra (6 mantras) |
| `/books/nbs/[chapter]/[verse]` | Nārada-bhakti-sūtra (8 sūtras) |
### Prose books (use per-book data layers + reading pages)
| Route Pattern | Books |
|---------------|-------|
| `/books/kb/[volume]/[slug]` | Kṛṣṇa Book |
| `/books/{code}/[chapter]` | NoD, TLC, EJ, EK, MG, BBD, OWK, RV, POY, PQPA, TTP, OCHKM, HKH |
| `/books/{code}/[chapter]` | NoD, TLC, EJ, EK, MG, BBD, OWK, RV, POY, PQPA, TTP, OCHKM, HKH, LCL, LOB, TLK, TQK, GG, MOG, CBHC, BK, PK |
### Hierarchical prose books (sub-chapters as separate pages)
@@ -345,14 +383,23 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
|---------------|-------|
| `/books/ssr/[chapter]` | Science of Self-Realization (slugs: "1-1", "1-2", "intro-foreword", etc.) |
| `/books/ds/[chapter]` | Dialectic Spiritualism (slugs: "1-1" = Socrates, "1-2" = Plato, etc.) |
| `/books/sva/[chapter]` | Songs of the Vaiṣṇava Ācāryas (slugs: "1-1" = Śrī Guru Praṇāma, etc.) |
### Essays
| Route | Content |
|-------|---------|
| `/essays` | All essays listing |
| `/essays` | All essays listing (10 essays across 4 groups) |
| `/essays/[slug]` | Individual essay reader |
### Other Documents
| Route | Content |
|-------|---------|
| `/other-documents` | All other documents listing |
| `/other-documents/[slug]` | Document index (chapter listing) |
| `/other-documents/[slug]/[chapter]` | Individual chapter reader |
### Other routes
| Route | Content |
+249
View File
@@ -0,0 +1,249 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getBkContent,
getBkSidebarItems,
type BkChapter,
type BkSidebarItem,
} from "@/lib/bk";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function BkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getBkContent(slug);
if (!content) notFound();
const sidebarItems = await getBkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const chapterNum = (content as BkChapter).chapter ?? null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/bk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Bhagavāner Kathā
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<BkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<BkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<BkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function BkSidebar({
items,
currentSlug,
}: {
items: BkSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
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">
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<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">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: BkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return (
<Link
href={`/books/bk/${item.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>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function BkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: BkSidebarItem | null;
next: BkSidebarItem | null;
sidebarItems: BkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Bhagavāner Kathā"
/>
{prev ? (
<Link
href={`/books/bk/${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.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/bk/${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.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBkMeta } from "@/lib/bk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function BkPage() {
const meta = await getBkMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* 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">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getCbhcContent,
getCbhcSidebarItems,
type CbhcChapter,
type CbhcSidebarItem,
} from "@/lib/cbhc";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function CbhcChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getCbhcContent(slug);
if (!content) notFound();
const sidebarItems = await getCbhcSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const chapterNum = (content as CbhcChapter).chapter ?? null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/cbhc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Caitanya-bhāgavata Commentary
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<CbhcSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<CbhcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<CbhcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function CbhcSidebar({
items,
currentSlug,
}: {
items: CbhcSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
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">
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<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">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: CbhcSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return (
<Link
href={`/books/cbhc/${item.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>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function CbhcNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: CbhcSidebarItem | null;
next: CbhcSidebarItem | null;
sidebarItems: CbhcSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/cbhc/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Śrī Caitanya-bhāgavata Commentary"
/>
{prev ? (
<Link
href={`/books/cbhc/${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.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/cbhc/${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.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getCbhcMeta } from "@/lib/cbhc";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function CbhcPage() {
const meta = await getCbhcMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* 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">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cbhc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getMogContent,
getMogSidebarItems,
type MogChapter,
type MogSidebarItem,
} from "@/lib/mog";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function MogChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getMogContent(slug);
if (!content) notFound();
const sidebarItems = await getMogSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as MogChapter).chapter === "number" &&
(content as MogChapter).chapter !== null;
const chapterNum = isChapter ? (content as MogChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/mog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Message of Godhead
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<MogSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MogNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<MogNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function MogSidebar({
items,
currentSlug,
}: {
items: MogSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
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">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<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">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: MogSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/mog/${item.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={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function MogNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: MogSidebarItem | null;
next: MogSidebarItem | null;
sidebarItems: MogSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mog/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mog/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Message of Godhead"
/>
{prev ? (
<Link
href={`/books/mog/${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.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/mog/${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.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMogMeta, getMogIntroSegments } from "@/lib/mog";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function MogPage() {
const meta = await getMogMeta();
if (!meta) notFound();
const introSegments = await getMogIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && 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/mog/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm: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">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mog/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPkContent,
getPkSidebarItems,
type PkChapter,
type PkSidebarItem,
} from "@/lib/pk";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPkContent(slug);
if (!content) notFound();
const sidebarItems = await getPkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as PkChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/pk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Prayers of King Kulaśekhara
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Verse {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<PkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PkSidebar({
items,
currentSlug,
}: {
items: PkSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
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">
{/* Chapters (Prayers) */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Prayers
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/pk/${item.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={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PkSidebarItem | null;
next: PkSidebarItem | null;
sidebarItems: PkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Prayers",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Prayers of King Kulaśekhara"
/>
{prev ? (
<Link
href={`/books/pk/${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.chapter != null
? `Prayer ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/pk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.chapter != null
? `Prayer ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPkMeta } from "@/lib/pk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function PkPage() {
const meta = await getPkMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters (Prayers) */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Prayers
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Verse {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getVaContent,
getVaSidebarItems,
type VaChapter,
type VaSidebarItem,
} from "@/lib/va";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export default async function VaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getVaContent(slug);
if (!content) notFound();
const sidebarItems = await getVaSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as VaChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/va"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Vaiśiṣṭyāṣṭakam
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
<VaSidebar items={sidebarItems} currentSlug={slug} />
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
<VaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
<VaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
function VaSidebar({
items,
currentSlug,
}: {
items: VaSidebarItem[];
currentSlug: string;
}) {
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">
{items.map((item) => (
<Link
key={item.slug}
href={`/books/va/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
item.slug === currentSlug
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}
function VaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: VaSidebarItem | null;
next: VaSidebarItem | null;
sidebarItems: VaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (sidebarItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: sidebarItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/va/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Vaiśiṣṭyāṣṭakam"
/>
{prev ? (
<Link
href={`/books/va/${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.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/va/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getVaMeta } from "@/lib/va";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function VaPage() {
const meta = await getVaMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
<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">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/va/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
export type EssayInfo = {
slug: string;
title: string;
year: number | null;
group: string | null;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
/** Human-readable group labels */
const GROUP_LABELS: Record<string, string> = {
"cc-essays": "Śrī Caitanya-caritāmṛta Essays",
"pre-1977-essays": "Pre-1977 Essays",
"two-essays": "Two Essays (1966)",
reviews: "Reviews",
standalone: "Standalone",
"typed-transcripts": "Typed Transcripts",
};
function groupLabel(group: string | null): string {
if (!group) return "Other";
return GROUP_LABELS[group] ?? group;
}
export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
const [query, setQuery] = useState("");
const normalizedEssays = useMemo(
() =>
essays.map((essay) => ({
essay,
searchText: normalize(
`${essay.title} ${essay.group ?? ""} ${essay.slug}`,
),
})),
[essays],
);
const filtered = useMemo(() => {
if (!query.trim()) return essays;
const q = normalize(query.trim());
return normalizedEssays
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.essay);
}, [essays, normalizedEssays, query]);
return (
<div className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search essays..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Results count when searching */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{filtered.length === 0
? "No essays found"
: `${filtered.length} essay${filtered.length === 1 ? "" : "s"} found`}
</p>
)}
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((essay) => (
<Link key={essay.slug} href={essay.href} className="block h-full">
<Card
className={cn(
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">
{essay.title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{essay.year && (
<Badge variant="secondary" className="text-xs">
{essay.year}
</Badge>
)}
{essay.group && (
<span className="text-muted-foreground text-xs">
{groupLabel(essay.group)}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+12 -27
View File
@@ -2,19 +2,27 @@ export const dynamic = "force-dynamic";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { prisma } from "@/lib/db";
import { EssaysGrid } from "./essays-grid";
async function getAllEssays() {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true, author: true, year: true, group: true },
select: { slug: true, title: true, year: true, group: true },
});
}
export default async function EssaysPage() {
const essays = await getAllEssays();
const items = essays.map((essay) => ({
slug: essay.slug,
title: essay.title,
year: essay.year,
group: essay.group,
href: `/essays/${essay.slug}`,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
@@ -30,31 +38,8 @@ export default async function EssaysPage() {
</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{essays.map((essay) => (
<li key={essay.slug}>
<Link
href={`/essays/${essay.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{essay.title}
</p>
<p className="text-sm text-muted-foreground">
{essay.author}
{essay.year ? ` · ${essay.year}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
<main className="max-w-6xl mx-auto px-6 py-8">
<EssaysGrid essays={items} />
</main>
</div>
);
+223
View File
@@ -0,0 +1,223 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getJournalContent,
getJournalMeta,
getJournalSidebarItems,
slugToBookCode,
type JournalSidebarItem,
} from "@/lib/journals";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export default async function JournalChapterPage({
params,
}: {
params: Promise<{ slug: string; chapter: string }>;
}) {
const { slug, chapter: chapterSlug } = await params;
const bookCode = slugToBookCode(slug);
const [meta, content, sidebarItems] = await Promise.all([
getJournalMeta(bookCode),
getJournalContent(bookCode, chapterSlug),
getJournalSidebarItems(bookCode),
]);
if (!meta || !content) notFound();
// Filter out heading items for prev/next navigation
const navItems = sidebarItems.filter((item) => item.type !== "heading");
const currentIdx = navItems.findIndex((item) => item.slug === chapterSlug);
const prev = currentIdx > 0 ? navItems[currentIdx - 1] : null;
const next =
currentIdx < navItems.length - 1 ? navItems[currentIdx + 1] : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/journals/${slug}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {meta.title}
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
<ChapterSidebar
items={sidebarItems}
currentSlug={chapterSlug}
docSlug={slug}
/>
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
<ChapterNav
prev={prev}
next={next}
navItems={navItems}
currentSlug={chapterSlug}
docSlug={slug}
docTitle={meta.title}
showMobileNav
/>
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
<ChapterNav
prev={prev}
next={next}
navItems={navItems}
currentSlug={chapterSlug}
docSlug={slug}
docTitle={meta.title}
/>
</main>
</div>
</div>
);
}
function ChapterSidebar({
items,
currentSlug,
docSlug,
}: {
items: JournalSidebarItem[];
currentSlug: string;
docSlug: string;
}) {
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">
{items.map((item, i) =>
item.type === "heading" ? (
<h3
key={`item-${i}`}
className="text-xs font-bold uppercase tracking-wider text-muted-foreground mt-4 mb-1 px-2"
>
{item.label}
</h3>
) : (
<Link
key={`item-${i}`}
href={`/journals/${docSlug}/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
item.slug === currentSlug
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
),
)}
</nav>
</aside>
);
}
function ChapterNav({
prev,
next,
navItems,
currentSlug,
docSlug,
docTitle,
showMobileNav = false,
}: {
prev: JournalSidebarItem | null;
next: JournalSidebarItem | null;
navItems: JournalSidebarItem[];
currentSlug: string;
docSlug: string;
docTitle: string;
showMobileNav?: boolean;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (showMobileNav && navItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: navItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/journals/${docSlug}/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{showMobileNav && (
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle={docTitle}
/>
)}
{prev ? (
<Link
href={`/journals/${docSlug}/${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.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/journals/${docSlug}/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getJournalMeta,
slugToBookCode,
type JournalChapterEntry,
} from "@/lib/journals";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function JournalIndexPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const bookCode = slugToBookCode(slug);
const meta = await getJournalMeta(bookCode);
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/journals"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Journals
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{meta.chapters.map((ch: JournalChapterEntry, idx: number) => {
// Top-level chapter with a slug (Preface, Introduction, etc.)
if ("slug" in ch && !("subChapters" in ch)) {
return (
<div key={ch.slug}>
<Card>
<CardContent className="p-0">
<Link
href={`/journals/${slug}/${ch.slug}`}
className="flex items-center px-5 py-4 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">{ch.title}</p>
</Link>
</CardContent>
</Card>
</div>
);
}
// Group with sub-chapters (may be 2 or 3 levels deep)
if ("subChapters" in ch) {
const group = ch as {
title: string;
subChapters: {
slug?: string;
title: string;
subChapters?: { slug: string; title: string }[];
}[];
};
return (
<div key={`group-${idx}`}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{group.title}
</h2>
<div className="space-y-4">
{group.subChapters.map((sc, scIdx) => {
// Sub-chapter with its own nested sub-chapters (e.g., month → days)
if ("subChapters" in sc && sc.subChapters) {
return (
<div key={`sc-${idx}-${scIdx}`}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2 ml-1">
{sc.title}
</h3>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{sc.subChapters.map((day) => (
<li key={day.slug}>
<Link
href={`/journals/${slug}/${day.slug}`}
className="flex items-center px-5 py-3 hover:bg-muted transition-colors"
>
<p className="text-sm font-medium text-foreground">
{day.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
// Direct sub-chapter with a slug (e.g., Summary, Notice)
if ("slug" in sc && sc.slug) {
return (
<Card key={`sc-${idx}-${scIdx}`}>
<CardContent className="p-0">
<Link
href={`/journals/${slug}/${sc.slug}`}
className="flex items-center px-5 py-3 hover:bg-muted transition-colors"
>
<p className="text-sm font-medium text-foreground">
{sc.title}
</p>
</Link>
</CardContent>
</Card>
);
}
return null;
})}
</div>
</div>
);
}
return null;
})}
</main>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { getAllJournals, bookCodeToSlug } from "@/lib/journals";
export default async function JournalsPage() {
const journals = await getAllJournals();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Journals</h1>
<p className="mt-1 text-muted-foreground">
Personal diaries and journals of Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{journals.map((journal) => (
<Link key={journal.book} href={`/journals/${bookCodeToSlug(journal.book)}`} className="block h-full">
<Card className="h-full transition-all hover:shadow-md hover:ring-foreground/20">
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">{journal.title}</CardTitle>
{journal.description && (
<CardDescription className="text-xs line-clamp-2">{journal.description}</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{journal.year && <Badge variant="secondary" className="text-xs">{journal.year}</Badge>}
{journal.totalChapters > 0 && (
<span className="text-muted-foreground text-xs">
{journal.totalChapters} {journal.totalChapters === 1 ? "chapter" : "chapters"}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</main>
</div>
);
}
@@ -0,0 +1,229 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOtherDocumentContent,
getOtherDocumentMeta,
getOtherDocumentSidebarItems,
slugToBookCode,
type OtherDocumentSidebarItem,
} from "@/lib/other-documents";
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";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OtherDocumentChapterPage({
params,
}: {
params: Promise<{ slug: string; chapter: string }>;
}) {
const { slug, chapter: chapterSlug } = await params;
const bookCode = slugToBookCode(slug);
const [meta, content, sidebarItems] = await Promise.all([
getOtherDocumentMeta(bookCode),
getOtherDocumentContent(bookCode, chapterSlug),
getOtherDocumentSidebarItems(bookCode),
]);
if (!meta || !content) notFound();
const currentIdx = sidebarItems.findIndex(
(item) => item.slug === chapterSlug,
);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/other-documents/${slug}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {meta.title}
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
<span className="text-primary">Chapter {content.chapter}: </span>
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<ChapterSidebar
items={sidebarItems}
currentSlug={chapterSlug}
docSlug={slug}
/>
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<ChapterNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={chapterSlug}
docSlug={slug}
docTitle={meta.title}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<ChapterNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={chapterSlug}
docSlug={slug}
docTitle={meta.title}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function ChapterSidebar({
items,
currentSlug,
docSlug,
}: {
items: OtherDocumentSidebarItem[];
currentSlug: string;
docSlug: string;
}) {
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">
{items.map((item) => (
<Link
key={item.slug}
href={`/other-documents/${docSlug}/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
item.slug === currentSlug
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function ChapterNav({
prev,
next,
sidebarItems,
currentSlug,
docSlug,
docTitle,
}: {
prev: OtherDocumentSidebarItem | null;
next: OtherDocumentSidebarItem | null;
sidebarItems: OtherDocumentSidebarItem[];
currentSlug: string;
docSlug: string;
docTitle: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (sidebarItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: sidebarItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/other-documents/${docSlug}/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle={docTitle}
/>
{prev ? (
<Link
href={`/other-documents/${docSlug}/${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]">
Ch. {prev.chapter}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/other-documents/${docSlug}/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
Ch. {next.chapter}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOtherDocumentMeta, slugToBookCode } from "@/lib/other-documents";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function OtherDocumentIndexPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const bookCode = slugToBookCode(slug);
const meta = await getOtherDocumentMeta(bookCode);
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/other-documents"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Other Documents
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
{meta.chapters.length > 0 && (
<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">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/other-documents/${slug}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
export type OtherDocumentInfo = {
slug: string;
title: string;
description: string;
year: number | null;
totalChapters: number;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
export function OtherDocumentsGrid({
items,
}: {
items: OtherDocumentInfo[];
}) {
const [query, setQuery] = useState("");
const normalizedItems = useMemo(
() =>
items.map((item) => ({
item,
searchText: normalize(
`${item.title} ${item.description} ${item.slug}`,
),
})),
[items],
);
const filtered = useMemo(() => {
if (!query.trim()) return items;
const q = normalize(query.trim());
return normalizedItems
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.item);
}, [items, normalizedItems, query]);
return (
<div className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search documents..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Results count when searching */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{filtered.length === 0
? "No documents found"
: `${filtered.length} document${filtered.length === 1 ? "" : "s"} found`}
</p>
)}
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((item) => (
<Link key={item.slug} href={item.href} className="block h-full">
<Card
className={cn(
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">
{item.title}
</CardTitle>
{item.description && (
<CardDescription className="text-xs line-clamp-2">
{item.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{item.year && (
<Badge variant="secondary" className="text-xs">
{item.year}
</Badge>
)}
{item.totalChapters > 0 && (
<span className="text-muted-foreground text-xs">
{item.totalChapters}{" "}
{item.totalChapters === 1 ? "chapter" : "chapters"}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { getAllOtherDocuments, bookCodeToSlug } from "@/lib/other-documents";
import { OtherDocumentsGrid } from "./other-documents-grid";
export default async function OtherDocumentsPage() {
const documents = await getAllOtherDocuments();
const items = documents.map((doc) => ({
slug: bookCodeToSlug(doc.book),
title: doc.title,
description: doc.description ?? "",
year: doc.year,
totalChapters: doc.totalChapters,
href: `/other-documents/${bookCodeToSlug(doc.book)}`,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Other Documents
</h1>
<p className="mt-1 text-muted-foreground">
Miscellaneous documents, analyses, and writings by Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-6xl mx-auto px-6 py-8">
<OtherDocumentsGrid items={items} />
</main>
</div>
);
}
+66 -1
View File
@@ -9,7 +9,14 @@ import {
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/page-header";
import { Logo } from "@/components/logo";
import { BookOpen, FileText, BookA, PenLine } from "lucide-react";
import {
BookOpen,
FileText,
BookA,
PenLine,
FileStack,
NotebookPen,
} from "lucide-react";
const allowRtf = process.env.ALLOW_RTF === "true";
@@ -76,6 +83,35 @@ export default function Home() {
</Card>
</Link>
{/* Journals */}
<Link href="/journals" className="block group">
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<NotebookPen className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Journals
</CardTitle>
<CardDescription>
Personal diaries &amp; journals
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Śrīla Prabhupāda&apos;s personal diaries — including the
historic Jaladuta Diary from his 1965 voyage to America and
The Beginning, his 1966 New York journal documenting the
founding of the Hare Kṛṣṇa movement.
</p>
</CardContent>
</Card>
</Link>
{/* Glossary */}
<Link href="/glossary" className="block group">
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
@@ -104,6 +140,35 @@ export default function Home() {
</Card>
</Link>
{/* Other Documents */}
<Link href="/other-documents" className="block group">
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<FileStack className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Other Documents
</CardTitle>
<CardDescription>
Miscellaneous analyses &amp; writings
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Miscellaneous documents, analyses, and writings by Śrīla
Prabhupāda that don&apos;t fit neatly into the books or essays
collections — including structured analyses with verse
references.
</p>
</CardContent>
</Card>
</Link>
{/* RTF Document Viewer — only visible when ALLOW_RTF=true */}
{allowRtf && (
<Link href="/rtfs" className="block group">
+3 -3
View File
@@ -87,11 +87,11 @@ export function MobileNav({
</h3>
)}
<div className="flex flex-col gap-0.5">
{group.items.map((item) => {
{group.items.map((item, ii) => {
if (item.isParent) {
return (
<div
key={item.slug}
key={`${gi}-${ii}`}
className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-2"
>
{item.label}
@@ -103,7 +103,7 @@ export function MobileNav({
const isChild = item.parentChapter != null && !item.isParent;
return (
<DrawerClose key={item.slug} asChild>
<DrawerClose key={`${gi}-${ii}`} asChild>
<Link
href={item.href}
className={cn(
+299
View File
@@ -0,0 +1,299 @@
/**
* Data access layer for Bhagavāner Kathā (BK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* BK is a simple prose book with no volume structure and no intro segments —
* just 2 chapters directly. We use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/bk/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "bk";
const BK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BkMeta = {
book: string;
title: string;
author: string;
year?: number | null;
description: string;
totalChapters: number;
chapters: BkChapterEntry[];
};
export type BkChapterEntry = {
number: number;
title: string;
};
export type BkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BkSidebarItem = {
/** URL-safe slug: "1", "2" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalChapter(chapterNum: number): BkChapter | null {
const filePath = path.join(
BK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<BkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getBkMeta(): Promise<BkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as BkMeta;
// Fallback to local JSON
const introPath = path.join(BK_JSON_DIR, "intro.json");
return readLocalJson<BkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–2).
*/
export async function getBkChapter(
chapterNum: number,
): Promise<BkChapter | null> {
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "1" … "2" → chapter
*/
export async function getBkContent(
slug: string,
): Promise<BkChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
return getBkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–2 (no intro segments)
*/
export async function getBkSidebarItems(): Promise<BkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getBkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(meta: BkMeta): BkSidebarItem[] {
const items: BkSidebarItem[] = [];
// Chapters 1–2 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: BkMeta | null): BkSidebarItem[] {
const items: BkSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(BK_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<BkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
}
+299
View File
@@ -0,0 +1,299 @@
/**
* Data access layer for Śrī Caitanya-bhāgavata Commentary (CBHC).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* CBHC is a simple prose book with no volume structure and no intro segments —
* just 4 chapters directly. We use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/cbhc/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "cbhc";
const CBHC_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "cbhc");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CbhcMeta = {
book: string;
title: string;
author: string;
year?: number | null;
description: string;
totalChapters: number;
chapters: CbhcChapterEntry[];
};
export type CbhcChapterEntry = {
number: number;
title: string;
};
export type CbhcChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type CbhcSidebarItem = {
/** URL-safe slug: "1", "2", "3", "4" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalChapter(chapterNum: number): CbhcChapter | null {
const filePath = path.join(
CBHC_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<CbhcChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getCbhcMeta(): Promise<CbhcMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as CbhcMeta;
// Fallback to local JSON
const introPath = path.join(CBHC_JSON_DIR, "intro.json");
return readLocalJson<CbhcMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getCbhcChapter(
chapterNum: number,
): Promise<CbhcChapter | null> {
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "1" … "4" → chapter
*/
export async function getCbhcContent(
slug: string,
): Promise<CbhcChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getCbhcChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–4 (no intro segments)
*/
export async function getCbhcSidebarItems(): Promise<CbhcSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getCbhcMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(meta: CbhcMeta): CbhcSidebarItem[] {
const items: CbhcSidebarItem[] = [];
// Chapters 1–4 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: CbhcMeta | null): CbhcSidebarItem[] {
const items: CbhcSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(CBHC_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<CbhcChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
}
+289
View File
@@ -0,0 +1,289 @@
/**
* Data access layer for Journals.
*
* Uses dedicated Prisma models:
* - JournalMeta (collection: journal_meta)
* - JournalChapter (collection: journal_chapters)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
const LOCAL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "journals");
// Types
export type JournalMetaData = {
book: string;
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
chapters: JournalChapterEntry[];
};
export type JournalSubChapter = { slug: string; title: string };
export type JournalMonthGroup = {
title: string;
subChapters: JournalSubChapter[];
};
export type JournalChapterGroup = {
title: string;
subChapters: (JournalSubChapter | JournalMonthGroup)[];
};
export type JournalChapterEntry =
| JournalSubChapter
| JournalMonthGroup
| JournalChapterGroup;
export type JournalChapterData = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
export type JournalSummary = {
book: string;
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
};
export type JournalSidebarItem = {
slug: string;
label: string;
type: string;
chapter: number;
};
// HTML sanitisation
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// Local JSON fallback helpers
function findLocalDirForBook(bookCode: string): string | null {
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
const entries = fs.readdirSync(LOCAL_JSON_DIR);
for (const entry of entries) {
const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json");
if (fs.existsSync(introPath)) {
try {
const intro = JSON.parse(fs.readFileSync(introPath, "utf-8"));
if (intro.book === bookCode) return entry;
} catch {
/* skip */
}
}
}
return null;
}
function readLocalJson<T>(filePath: string): T | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getAllLocalMetas(): JournalMetaData[] {
if (!fs.existsSync(LOCAL_JSON_DIR)) return [];
const entries = fs.readdirSync(LOCAL_JSON_DIR).sort();
const metas: JournalMetaData[] = [];
for (const entry of entries) {
const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json");
const intro = readLocalJson<JournalMetaData>(introPath);
if (intro) metas.push(intro);
}
return metas;
}
function getLocalMeta(bookCode: string): JournalMetaData | null {
const dir = findLocalDirForBook(bookCode);
if (!dir) return null;
return readLocalJson<JournalMetaData>(
path.join(LOCAL_JSON_DIR, dir, "intro.json"),
);
}
function getLocalChapterBySlug(
bookCode: string,
slug: string,
): JournalChapterData | null {
const dir = findLocalDirForBook(bookCode);
if (!dir) return null;
// Try slug directly as filename (e.g. "preface.json", "apr-3.json")
const filePath = path.join(LOCAL_JSON_DIR, dir, "chapters", `${slug}.json`);
const data = readLocalJson<JournalChapterData>(filePath);
if (!data) return null;
return { ...data, paragraphs: data.paragraphs.map(sanitizeHtml) };
}
// Public API
export async function getAllJournals(): Promise<JournalSummary[]> {
try {
const results = await prisma.journalMeta.findMany({
orderBy: { book: "asc" },
});
if (results.length > 0) {
return results.map((row) => {
const data = row.data as unknown as JournalMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters,
};
});
}
} catch {
/* fall through */
}
const localMetas = getAllLocalMetas();
return localMetas.map((meta) => ({
book: meta.book,
title: meta.title,
author: meta.author,
year: meta.year ?? null,
description: meta.description ?? null,
totalChapters: meta.totalChapters,
}));
}
export async function getJournalMeta(
bookCode: string,
): Promise<JournalMetaData | null> {
try {
const row = await prisma.journalMeta.findUnique({
where: { book: bookCode },
});
if (row) return row.data as unknown as JournalMetaData;
} catch {
/* fall through */
}
return getLocalMeta(bookCode);
}
export async function getJournalChapterBySlug(
bookCode: string,
slug: string,
): Promise<JournalChapterData | null> {
if (!slug) return null;
try {
const doc = await prisma.journalChapter.findUnique({
where: { book_slug: { book: bookCode, slug } },
});
if (doc)
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} catch {
/* fall through */
}
return getLocalChapterBySlug(bookCode, slug);
}
export async function getJournalContent(
bookCode: string,
slug: string,
): Promise<JournalChapterData | null> {
return getJournalChapterBySlug(bookCode, slug);
}
function isSubChapter(ch: JournalChapterEntry): ch is JournalSubChapter {
return "slug" in ch && !("subChapters" in ch);
}
function hasSubChapters(
ch: JournalChapterEntry,
): ch is JournalMonthGroup | JournalChapterGroup {
return "subChapters" in ch && Array.isArray(ch.subChapters);
}
export async function getJournalSidebarItems(
bookCode: string,
): Promise<JournalSidebarItem[]> {
const meta = await getJournalMeta(bookCode);
if (!meta) return [];
const items: JournalSidebarItem[] = [];
function addEntry(entry: JournalChapterEntry) {
if (isSubChapter(entry)) {
items.push({
slug: entry.slug,
label: entry.title,
type: "chapter",
chapter: 0,
});
} else if (hasSubChapters(entry)) {
// Group heading (place name or month name)
items.push({
slug: "",
label: entry.title,
type: "heading",
chapter: 0,
});
for (const sc of entry.subChapters) {
addEntry(sc);
}
}
}
for (const ch of meta.chapters) {
addEntry(ch);
}
return items;
}
export function bookCodeToSlug(bookCode: string): string {
return bookCode;
}
export function slugToBookCode(slug: string): string {
return slug;
}
+430
View File
@@ -0,0 +1,430 @@
/**
* Data access layer for Message of Godhead (MOG).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* MOG is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/mog/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "mog";
const MOG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mog");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MogMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: MogChapterEntry[];
};
export type MogChapterEntry = {
number: number;
title: string;
};
export type MogIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type MogChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type MogSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", "3" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): MogIntroSegment[] {
const introDir = path.join(MOG_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: MogIntroSegment[] = [];
const files = ["front-pages.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<MogIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): MogIntroSegment | null {
const filePath = path.join(MOG_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<MogIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): MogChapter | null {
const filePath = path.join(
MOG_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<MogChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getMogMeta(): Promise<MogMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as MogMeta;
// Fallback to local JSON
const introPath = path.join(MOG_JSON_DIR, "intro.json");
return readLocalJson<MogMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getMogIntroSegments(): Promise<MogIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getMogIntroSegment(
slug: string,
): Promise<MogIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–3).
*/
export async function getMogChapter(
chapterNum: number,
): Promise<MogChapter | null> {
if (chapterNum < 1 || chapterNum > 3 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "3" → chapter
*/
export async function getMogContent(
slug: string,
): Promise<MogChapter | MogIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getMogIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 3) {
return getMogChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–3
*/
export async function getMogSidebarItems(): Promise<MogSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getMogMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: MogMeta,
allDocs: {
slug: string;
title: string;
type: string;
chapter: number | null;
order: number | null;
}[],
): MogSidebarItem[] {
const items: MogSidebarItem[] = [];
// Intro segments (front-pages)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–3 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: MogMeta | null): MogSidebarItem[] {
const items: MogSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
for (const seg of introSegs) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(MOG_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<MogChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `${num}. ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
}
+327
View File
@@ -0,0 +1,327 @@
/**
* Data access layer for Other Documents.
*
* Uses dedicated Prisma models:
* - OtherDocumentMeta (collection: other_document_meta)
* - OtherDocumentChapter (collection: other_document_chapters)
*
* Falls back to reading local JSON files from
* lib/jsons/other-documents/<slug>/ when the database is unavailable.
*
* Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula").
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LOCAL_JSON_DIR = path.join(
process.cwd(),
"lib",
"jsons",
"other-documents",
);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OtherDocumentMetaData = {
book: string;
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
chapters: OtherDocumentChapterEntry[];
};
export type OtherDocumentChapterEntry = {
number: number;
title: string;
};
export type OtherDocumentChapterData = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
/** Lightweight summary for the top-level index page. */
export type OtherDocumentSummary = {
book: string;
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
};
/** Used in sidebar / table-of-contents navigation. */
export type OtherDocumentSidebarItem = {
slug: string;
label: string;
type: string;
chapter: number;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Helpers: local JSON fallback
// ---------------------------------------------------------------------------
function findLocalDirForBook(bookCode: string): string | null {
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
const entries = fs.readdirSync(LOCAL_JSON_DIR);
for (const entry of entries) {
const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json");
if (fs.existsSync(introPath)) {
try {
const intro = JSON.parse(fs.readFileSync(introPath, "utf-8"));
if (intro.book === bookCode) return entry;
} catch {
// skip
}
}
}
return null;
}
function readLocalJson<T>(filePath: string): T | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getAllLocalMetas(): OtherDocumentMetaData[] {
if (!fs.existsSync(LOCAL_JSON_DIR)) return [];
const entries = fs.readdirSync(LOCAL_JSON_DIR).sort();
const metas: OtherDocumentMetaData[] = [];
for (const entry of entries) {
const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json");
const intro = readLocalJson<OtherDocumentMetaData>(introPath);
if (intro) metas.push(intro);
}
return metas;
}
function getLocalMeta(bookCode: string): OtherDocumentMetaData | null {
const dir = findLocalDirForBook(bookCode);
if (!dir) return null;
return readLocalJson<OtherDocumentMetaData>(
path.join(LOCAL_JSON_DIR, dir, "intro.json"),
);
}
function getLocalChapter(
bookCode: string,
chapterNum: number,
): OtherDocumentChapterData | null {
const dir = findLocalDirForBook(bookCode);
if (!dir) return null;
const filePath = path.join(
LOCAL_JSON_DIR,
dir,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
const data = readLocalJson<OtherDocumentChapterData>(filePath);
if (!data) return null;
return {
...data,
paragraphs: data.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* List all other documents (lightweight summaries for the index page).
*/
export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> {
try {
const results = await prisma.otherDocumentMeta.findMany({
orderBy: { book: "asc" },
});
if (results.length > 0) {
return results.map((row) => {
const data = row.data as unknown as OtherDocumentMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters,
};
});
}
} catch {
// fall through to local JSON
}
const localMetas = getAllLocalMetas();
return localMetas.map((meta) => ({
book: meta.book,
title: meta.title,
author: meta.author,
year: meta.year ?? null,
description: meta.description ?? null,
totalChapters: meta.totalChapters,
}));
}
/**
* Get metadata for a single other document by book code.
*/
export async function getOtherDocumentMeta(
bookCode: string,
): Promise<OtherDocumentMetaData | null> {
try {
const row = await prisma.otherDocumentMeta.findUnique({
where: { book: bookCode },
});
if (row) {
return row.data as unknown as OtherDocumentMetaData;
}
} catch {
// fall through to local JSON
}
return getLocalMeta(bookCode);
}
/**
* Get a single chapter from an other document.
*/
export async function getOtherDocumentChapter(
bookCode: string,
chapterNum: number,
): Promise<OtherDocumentChapterData | null> {
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
try {
const doc = await prisma.otherDocumentChapter.findUnique({
where: {
book_slug: {
book: bookCode,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
} catch {
// fall through to local JSON
}
return getLocalChapter(bookCode, chapterNum);
}
/**
* Unified content accessor — get content by URL slug.
*/
export async function getOtherDocumentContent(
bookCode: string,
slug: string,
): Promise<OtherDocumentChapterData | null> {
const num = Number(slug);
if (Number.isNaN(num) || !Number.isInteger(num) || num < 1) return null;
return getOtherDocumentChapter(bookCode, num);
}
/**
* Build the sidebar navigation list for an other document.
*/
export async function getOtherDocumentSidebarItems(
bookCode: string,
): Promise<OtherDocumentSidebarItem[]> {
const meta = await getOtherDocumentMeta(bookCode);
if (!meta) return [];
return meta.chapters.map((ch) => ({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
}));
}
/**
* Derive the URL slug from a book code.
*/
export function bookCodeToSlug(bookCode: string): string {
return bookCode;
}
/**
* Derive the book code from a URL slug.
*/
export function slugToBookCode(slug: string): string {
return slug;
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Data access layer for Prayers of King Kulaśekhara (PK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* PK is a prose book with no volume structure and no intro segments —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/pk/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "pk";
const PK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "pk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PkMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: PkChapterEntry[];
};
export type PkChapterEntry = {
number: number;
title: string;
};
export type PkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PkSidebarItem = {
/** URL-safe slug: "1", "2", … "6" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalChapter(chapterNum: number): PkChapter | null {
const filePath = path.join(
PK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<PkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPkMeta(): Promise<PkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as PkMeta;
// Fallback to local JSON
const introPath = path.join(PK_JSON_DIR, "intro.json");
return readLocalJson<PkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–6).
*/
export async function getPkChapter(
chapterNum: number,
): Promise<PkChapter | null> {
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "1" … "6" → chapter
*/
export async function getPkContent(slug: string): Promise<PkChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
return getPkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–6 (no intro segments for PK)
*/
export async function getPkSidebarItems(): Promise<PkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: PkMeta,
_allDocs: {
slug: string;
title: string;
type: string;
chapter: number | null;
order: number | null;
}[],
): PkSidebarItem[] {
const items: PkSidebarItem[] = [];
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: PkMeta | null): PkSidebarItem[] {
const items: PkSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(PK_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<PkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `${num}. ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Data access layer for Vaiśiṣṭyāṣṭakam (VA).
*
* Reads from MongoDB via Prisma.
* VA is a poetic work with 8 chapters (Vaiśiṣṭyas) and no intro segments.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BOOK = "va";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type VaMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: VaChapterEntry[];
};
export type VaChapterEntry = {
number: number;
title: string;
};
export type VaChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Used in sidebar / table-of-contents navigation. */
export type VaSidebarItem = {
slug: string;
label: string;
type: string;
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
export async function getVaMeta(): Promise<VaMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as VaMeta;
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
export async function getVaChapter(
chapterNum: number,
): Promise<VaChapter | null> {
if (chapterNum < 1 || chapterNum > 8 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
export async function getVaContent(
slug: string,
): Promise<VaChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
return getVaChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
export async function getVaSidebarItems(): Promise<VaSidebarItem[]> {
const meta = await getVaMeta();
if (!meta) return [];
return meta.chapters.map((ch) => ({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
}));
}
+80
View File
@@ -118,6 +118,86 @@ model Essay {
@@map("essays")
}
// ---------------------------------------------------------------------------
// OtherDocumentMeta — metadata for miscellaneous documents
//
// book: slug identifier (e.g. "analysis-bg", "real-peace-formula")
// data: JSON blob containing title, author, year, description, chapters list
// ---------------------------------------------------------------------------
model OtherDocumentMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("other_document_meta")
}
// ---------------------------------------------------------------------------
// OtherDocumentChapter — chapters within other documents
//
// book: slug identifier matching OtherDocumentMeta.book
// chapter: chapter number
// title: chapter title
// type: "chapter"
// slug: URL-friendly identifier (chapter number as string)
// order: sort order
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model OtherDocumentChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@map("other_document_chapters")
}
// ---------------------------------------------------------------------------
// JournalMeta — metadata for journals (Jaladuta Diary, The Beginning, etc.)
//
// book: slug identifier (e.g. "jaladuta-diary", "the-beginning")
// data: JSON blob containing title, author, year, description, chapters list
// ---------------------------------------------------------------------------
model JournalMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("journal_meta")
}
// ---------------------------------------------------------------------------
// JournalChapter — chapters within journals
//
// book: slug identifier matching JournalMeta.book
// chapter: chapter number
// title: chapter title
// type: "chapter" | "preface" | "introduction"
// slug: URL-friendly identifier (chapter number as string)
// order: sort order
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model JournalChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@map("journal_chapters")
}
// ---------------------------------------------------------------------------
// GlossaryEntry — individual glossary term + definition
// ---------------------------------------------------------------------------
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""Extract Bhagavāner Kathā (part of Renunciation through Wisdom) from others-before-1977.html.
The book has 2 chapters (no intro segments):
1. This World of Misery
2. The Cause of Misery
Source: public/html/others-before-1977.html, lines ~4182–4289.
Start: <p class="p56"> containing "Bhagaväner kathä"
End: <p class="p35"> containing "Indian Bhagavatams" (stop before)
Chapter boundaries: <p class="p17"> tags containing "Bk:Chapter"
IMPORTANT: Unlike MOG/CBHC, this book's body text uses ScaGoudy font encoding.
Characters like ä, å, ñ, ö, ë, ò, ç, ì, ï, ü, é, à, ù must be decoded to
proper IAST Unicode (ā, ṛ, ṣ, ṭ, ṇ, ḍ, ś, ṅ, ñ, ū, ī, ṁ, ḥ).
CSS classes:
p56 — Book title header (skip)
p17 — Chapter bookmark headers ("Bk:Chapter N") — boundary detection, skip
p5 — Chapter title headings (skip — we derive titles ourselves)
p6 — Body paragraphs (MAIN content) — HAS ScaGoudy encoding
p12 — Body paragraph variant (longer text) — HAS ScaGoudy encoding
p38 — Body paragraph variant — HAS ScaGoudy encoding
p9 — Sanskrit verse lines (italic, with <span class="s3">) — centered italic
p10 — Reference markers like [RTW 1.1] — include as paragraphs
p61 — Reference marker variant [RTW 1.2] — include
p57 — Footnote header ("Footnote") — include
p58 — Footnote text — include
p19 — Sanskrit verse block (multi-line) — centered italic
p59 — Sanskrit verse block variant — centered italic
p23 — Sanskrit verse line variant — centered italic
p36 — Sanskrit verse line variant — centered italic
p60 — Sanskrit verse line variant — centered italic
Output:
lib/jsons/books/bk/intro.json — Book metadata
lib/jsons/books/bk/chapters/01.json — Chapter 1: This World of Misery
lib/jsons/books/bk/chapters/02.json — Chapter 2: The Cause of Misery
Usage:
python3 scripts/bk_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"Ì": "Ṅ",
"ï": "ñ",
"Ï": "Ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"À": "Ṁ",
"ù": "ḥ",
"Ù": "Ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
# Build a translation table for fast character-by-character replacement
_SCAGOUDY_TRANS = str.maketrans(
{k: v for k, v in SCAGOUDY_TO_IAST.items() if len(k) == 1}
)
def scagoudy_decode(text: str) -> str:
"""Decode ScaGoudy-encoded text to proper IAST Unicode."""
return text.translate(_SCAGOUDY_TRANS)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def decode_html_content(tag: Tag, apply_scagoudy: bool = True) -> str:
"""Extract inner HTML from a tag, preserve formatting tags, strip spans,
and optionally apply ScaGoudy decoding.
Steps:
1. Get inner HTML
2. Strip <span class="sN"> wrappers (keep content)
3. Apply ScaGoudy decode to the entire result (if requested)
4. Normalize whitespace
"""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes (RTF conversion artifacts) — keep content
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Clean up Apple-tab-span leftovers (tab characters)
inner = inner.replace("\t", " ")
# Normalize whitespace
inner = re.sub(r"\s+", " ", inner).strip()
# Apply ScaGoudy decoding
if apply_scagoudy:
inner = scagoudy_decode(inner)
return inner
def decode_html_content_as_verse(tag: Tag) -> str:
"""Extract inner HTML, strip spans, ScaGoudy decode, wrap as centered italic.
For Sanskrit verse lines (p9, p19, p59, p23, p36, p60).
Preserves <br> and reference tags like [Bg. 7.14].
"""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes — keep content
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Clean up tab characters
inner = inner.replace("\t", " ")
# Normalize spaces (but preserve <br> tags)
# Replace multiple spaces with single space
inner = re.sub(r"[ ]+", " ", inner).strip()
# Remove trailing <br> tags
inner = re.sub(r"(<br\s*/?\s*>)+\s*$", "", inner).strip()
# Apply ScaGoudy decoding
inner = scagoudy_decode(inner)
# Wrap as centered italic
if inner:
return f'<div style="text-align:center"><i>{inner}</i></div>'
return ""
def decode_text(tag: Tag) -> str:
"""Get plain text from a tag."""
return tag.get_text().strip()
def get_css_class(tag: Tag) -> str:
"""Get the first CSS class of a tag, or empty string."""
classes = tag.get("class", [])
return classes[0] if classes else ""
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty or whitespace-only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# CSS class categorization for BK
# ---------------------------------------------------------------------------
# Book title header
BOOK_TITLE_CLASS = "p56"
# Chapter bookmark headers (used for boundary detection)
CHAPTER_BOOKMARK_CLASS = "p17"
# Chapter title (bold) — skip from body
CHAPTER_TITLE_CLASS = "p5"
# Body paragraph classes (all need ScaGoudy decoding)
BODY_CLASSES = frozenset(["p6", "p12", "p38"])
# Sanskrit verse line classes (wrap as centered italic)
VERSE_CLASSES = frozenset(["p9", "p19", "p59", "p23", "p36", "p60"])
# Reference marker classes — include as paragraphs
REFERENCE_CLASSES = frozenset(["p10", "p61"])
# Footnote classes — include as paragraphs
FOOTNOTE_CLASSES = frozenset(["p57", "p58"])
# Classes to skip entirely
SKIP_CLASSES = frozenset(["p43", "p24"])
# TOC entry class
TOC_CLASS = "p2"
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_bk_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the BK content.
Start: <p class="p56"> containing "Bhagaväner kathä"
End: <p class="p35"> containing "Indian Bhagavatams" (stop before)
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = decode_text(p)
# Find start: p56 containing "Bhagaväner" or p2 TOC entry
if start_idx is None:
if cls == BOOK_TITLE_CLASS and "Bhag" in text and "kath" in text:
start_idx = i
continue
continue
# Find end: p35 containing "Indian Bhagavatams"
if cls == "p35" and "Indian" in text and "Bhagavatam" in text:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Bhagaväner kathä' in others-before-1977.html!")
sys.exit(1)
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all BK chapter bookmark tags (p17) in the BK range.
Returns list of (paragraph_index, raw_title_text).
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_BOOKMARK_CLASS:
text = decode_text(p).strip()
if text and "Bk:" in text:
bookmarks.append((i, text))
return bookmarks
# ---------------------------------------------------------------------------
# Paragraph extraction
# ---------------------------------------------------------------------------
def extract_paragraphs(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Extract body paragraphs for a chapter section.
Collects:
- p6, p12, p38 (body paragraphs — ScaGoudy decoded)
- p9, p19, p59, p23, p36, p60 (verse lines — centered italic, ScaGoudy decoded)
- p10, p61 (reference markers — ScaGoudy decoded)
- p57, p58 (footnotes — ScaGoudy decoded)
Skips:
- p56 (book title), p17 (bookmarks), p5 (chapter titles), p2 (TOC), empties
"""
result = []
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 known skip classes
if cls in SKIP_CLASSES:
continue
# Skip book title header
if cls == BOOK_TITLE_CLASS:
continue
# Skip chapter bookmark headers
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip chapter title headings (p5)
if cls == CHAPTER_TITLE_CLASS:
continue
# Skip TOC entries (p2)
if cls == TOC_CLASS:
continue
# Body paragraphs (ScaGoudy decoded)
if cls in BODY_CLASSES:
html = decode_html_content(p, apply_scagoudy=True)
if html:
result.append(html)
continue
# Sanskrit verse lines (centered italic, ScaGoudy decoded)
if cls in VERSE_CLASSES:
html = decode_html_content_as_verse(p)
if html:
result.append(html)
continue
# Reference markers (ScaGoudy decoded)
if cls in REFERENCE_CLASSES:
html = decode_html_content(p, apply_scagoudy=True)
if html:
result.append(html)
continue
# Footnote classes (ScaGoudy decoded)
if cls in FOOTNOTE_CLASSES:
html = decode_html_content(p, apply_scagoudy=True)
if html:
result.append(html)
continue
# Fallback: try to extract content from unknown classes
html = decode_html_content(p, apply_scagoudy=True)
if html:
print(f" ⚠ Unknown class '{cls}' at paragraph {i}: {html[:80]}...")
result.append(html)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_bk(soup: BeautifulSoup) -> dict:
"""Extract the entire Bhagavāner Kathā from the parsed HTML.
Returns a dict with:
"chapters": list of chapter dicts
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find BK boundaries
bk_start, bk_end = find_bk_boundaries(all_paragraphs)
print(f" BK content: paragraphs {bk_start} to {bk_end}")
print(f" Total paragraphs in range: {bk_end - bk_start}")
# Step 2: Find all chapter bookmarks (p17 with "Bk:")
bookmarks = find_chapter_bookmarks(all_paragraphs, bk_start, bk_end)
print(f" Found {len(bookmarks)} BK chapter bookmarks:")
for pos, title in bookmarks:
print(f" Paragraph {pos}: {title}")
# Step 3: Map bookmarks to chapters with proper titles
# Expected: 2 chapters
chapter_titles_map = {
1: "This World of Misery",
2: "The Cause of Misery",
}
chapter_bookmarks = [] # (para_idx, chapter_num, normalized_title)
for idx, (pos, _raw_title) in enumerate(bookmarks):
ch_num = idx + 1
ch_title = chapter_titles_map.get(ch_num, f"Chapter {ch_num}")
chapter_bookmarks.append((pos, ch_num, ch_title))
print(f"\n Mapped {len(chapter_bookmarks)} chapters:")
for pos, num, title in chapter_bookmarks:
print(f" Chapter {num}: {title} (paragraph {pos})")
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
# Content starts after the bookmark line (skip p17 and p5 heading)
content_start = ch_pos + 1
# Content ends at next chapter bookmark or bk_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = bk_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "bk",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 5: Build chapter listing for intro.json
chapter_listing = [
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
]
return {
"chapters": chapters,
"chapter_listing": chapter_listing,
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
"""Write data as formatted JSON to filepath, creating directories as needed."""
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", "others-before-1977.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "bk")
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 Bhagavāner Kathā...\n")
result = extract_bk(soup)
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "bk",
"title": "Bhagavāner Kathā",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": None,
"description": (
"Early Bengali writings on the topics of Godhead "
"— part of 'Renunciation through Wisdom' (Vairāgya-vidyā)."
),
"totalChapters": 2,
"chapters": chapter_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write chapters ---
print(f"\n--- Chapters ---")
for ch in chapters:
ch_num_str = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}"
)
# --- Summary ---
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Chapters: {len(chapters)}")
for ch in chapters:
print(
f" Ch {ch['chapter']}: {len(ch['paragraphs'])} paragraphs — {ch['title']}"
)
print(f" Total paragraphs: {total_chapter_paras}")
if chapters:
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
for ch in chapters:
ch_num = ch["chapter"]
print(f"\n--- Sample: Chapter {ch_num} ({ch['title']}) ---")
for i, p in enumerate(ch["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(ch["paragraphs"]) > 3:
print(f" ... ({len(ch['paragraphs']) - 3} more paragraphs)")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 2 chapters present
expected_chapters = set(range(1, 3))
actual_chapters = set(ch["chapter"] for ch in chapters)
missing = expected_chapters - actual_chapters
extra = actual_chapters - expected_chapters
if missing:
print(f" ⚠ Missing chapters: {sorted(missing)}")
if extra:
print(f" ⚠ Extra chapters: {sorted(extra)}")
if not missing and not extra:
print(f" ✅ All 2 chapters present")
# Check empty chapters
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
if empty_chapters:
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
else:
print(f" ✅ No empty chapters")
# Check ScaGoudy decoding worked — look for common ScaGoudy characters
scagoudy_chars = set("äåñöëòçìïüéàù")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"])
remaining_scagoudy = [c for c in all_text if c in scagoudy_chars]
if remaining_scagoudy:
# Count occurrences
from collections import Counter
counts = Counter(remaining_scagoudy)
print(
f" ⚠ Remaining ScaGoudy characters found (may be false positives): {dict(counts)}"
)
else:
print(f" ✅ No ScaGoudy characters remaining — decoding looks clean")
# Check for IAST characters (positive signal)
iast_chars = set("āṛṣṭṇḍśṅñūīṁḥ")
iast_found = [c for c in all_text if c in iast_chars]
if iast_found:
from collections import Counter
counts = Counter(iast_found)
print(f" ✅ IAST Unicode characters present: {dict(counts)}")
else:
print(f" ⚠ No IAST Unicode characters found — decoding may have failed")
# Check chapter 1 has [RTW 1.1] reference
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"])
if "RTW" in ch1_text and "1.1" in ch1_text:
print(f" ✅ '[RTW 1.1]' reference found in Chapter 1")
else:
print(f" ⚠ '[RTW 1.1]' reference NOT found in Chapter 1")
# Check chapter 2 has [RTW 1.2] reference
if len(chapters) >= 2:
ch2_text = " ".join(chapters[1]["paragraphs"])
if "RTW" in ch2_text and "1.2" in ch2_text:
print(f" ✅ '[RTW 1.2]' reference found in Chapter 2")
else:
print(f" ⚠ '[RTW 1.2]' reference NOT found in Chapter 2")
# Check for content leak from next work (Indian Bhagavatams)
if "Indian Bhagavatam" in all_text:
print(f" ⚠ Content from next work (Indian Bhagavatams) may have leaked!")
else:
print(f" ✅ No content leak from next work detected")
# Check Kṛṣṇa appears (decoded from Kåñëa)
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' properly decoded (from 'Kåñëa')")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may need review")
# Check last chapter ends cleanly
if chapters:
last_ch = chapters[-1]
last_para = last_ch["paragraphs"][-1] if last_ch["paragraphs"] else ""
last_preview = re.sub(r"<[^>]+>", "", last_para)[:100]
print(f' ℹ Last paragraph of Ch {last_ch["chapter"]}: "{last_preview}..."')
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Extract Śrī Caitanya-bhāgavata Commentary from others-before-1977.html.
The book has 4 chapters (no intro segments):
1. Chapter 1
2. Lord Caitanya's Appearance
3. Chapter 3
4. Chapter 4
Source: public/html/others-before-1977.html, lines ~4139–4180.
Start: <p class="p3"><b>Çré Caitanya-bhägavata-Commentary</b></p>
End: <p class="p2">Bhagaväner kathä [Original Documents]</p> (stop before)
Chapter boundaries: <p class="p17"> tags containing "Caitanya-bhägavata"
CSS classes:
p3 — Book title header (skip)
p17 — Chapter bookmark headers (boundary detection, skip from output)
p5 — Chapter title headings (skip — we derive titles ourselves)
p6 — Body paragraphs (MAIN content)
p38 — Body paragraph variant
p55 — Body paragraph variant (last paragraph of chapter 4)
p9 — End-of-chapter markers like "(End Chapter One)" — include
Output:
lib/jsons/books/cbhc/intro.json — Book metadata
lib/jsons/books/cbhc/chapters/01.json — Chapter 1
lib/jsons/books/cbhc/chapters/02.json — Chapter 2
lib/jsons/books/cbhc/chapters/03.json — Chapter 3
lib/jsons/books/cbhc/chapters/04.json — Chapter 4
Usage:
python3 scripts/cbhc_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, preserve formatting tags, strip spans."""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Clean up Apple-tab-span leftovers (tab characters)
inner = inner.replace("\t", " ")
# Normalize whitespace
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
"""Get plain text from a tag."""
return tag.get_text().strip()
def get_css_class(tag: Tag) -> str:
"""Get the first CSS class of a tag, or empty string."""
classes = tag.get("class", [])
return classes[0] if classes else ""
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty or whitespace-only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# CSS class categorization for CBHC
# ---------------------------------------------------------------------------
# Book title header
BOOK_TITLE_CLASS = "p3"
# Chapter bookmark headers (used for boundary detection)
CHAPTER_BOOKMARK_CLASS = "p17"
# Chapter title (bold, e.g., "Chapter 1") — skip from body
CHAPTER_TITLE_CLASS = "p5"
# Body paragraph classes
BODY_CLASSES = frozenset(["p6", "p38", "p55"])
# End-of-chapter markers — include as a paragraph
END_MARKER_CLASS = "p9"
# Classes to skip entirely
SKIP_CLASSES = frozenset(["p43", "p24"])
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_cbhc_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the CBHC content.
Start: <p class="p3"> containing "Caitanya-bhägavata-Commentary"
End: <p class="p2"> containing "Bhagaväner kathä" (next work)
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = decode_text(p)
# Find start: p3 containing "Caitanya-bhägavata"
if start_idx is None:
if cls == BOOK_TITLE_CLASS and "Caitanya-bh" in text:
start_idx = i
continue
continue
# Find end: p2 containing "Bhagaväner" (next work boundary)
if cls == "p2" and "Bhag" in text and "kath" in text:
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Çré Caitanya-bhägavata-Commentary' "
"in others-before-1977.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all CBHC chapter bookmark tags (p17) in the CBHC range.
Returns list of (paragraph_index, raw_title_text).
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_BOOKMARK_CLASS:
text = decode_text(p).strip()
if text and "Caitanya-bh" in text:
bookmarks.append((i, text))
return bookmarks
# ---------------------------------------------------------------------------
# Paragraph extraction
# ---------------------------------------------------------------------------
def extract_paragraphs(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Extract body paragraphs for a chapter section.
Collects p6, p38, p55, and p9 (end markers).
Skips p3 (book header), p17 (bookmarks), p5 (chapter titles), p2, empties.
"""
result = []
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 known skip classes
if cls in SKIP_CLASSES:
continue
# Skip book title header
if cls == BOOK_TITLE_CLASS:
continue
# Skip chapter bookmark headers
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip chapter title headings (p5)
if cls == CHAPTER_TITLE_CLASS:
continue
# Skip p2 (TOC entries)
if cls == "p2":
continue
# Body paragraphs
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# End-of-chapter markers (p9) — include as a paragraph
if cls == END_MARKER_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Fallback: try to extract content from unknown classes
html = decode_html_content(p)
if html:
print(f" ⚠ Unknown class '{cls}' at paragraph {i}: {html[:80]}...")
result.append(html)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_cbhc(soup: BeautifulSoup) -> dict:
"""Extract the entire Śrī Caitanya-bhāgavata Commentary from the parsed HTML.
Returns a dict with:
"chapters": list of chapter dicts
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find CBHC boundaries
cbhc_start, cbhc_end = find_cbhc_boundaries(all_paragraphs)
print(f" CBHC content: paragraphs {cbhc_start} to {cbhc_end}")
print(f" Total paragraphs in range: {cbhc_end - cbhc_start}")
# Step 2: Find all chapter bookmarks (p17)
bookmarks = find_chapter_bookmarks(all_paragraphs, cbhc_start, cbhc_end)
print(f" Found {len(bookmarks)} CBHC chapter bookmarks:")
for pos, title in bookmarks:
print(f" Paragraph {pos}: {title}")
# Step 3: Map bookmarks to chapters with proper titles
# Expected: 4 chapters
# We derive proper titles from the p5 heading that follows each bookmark
chapter_titles_map = {
1: "Chapter 1",
2: "Lord Caitanya's Appearance",
3: "Chapter 3",
4: "Chapter 4",
}
chapter_bookmarks = [] # (para_idx, chapter_num, normalized_title)
for idx, (pos, _raw_title) in enumerate(bookmarks):
ch_num = idx + 1
ch_title = chapter_titles_map.get(ch_num, f"Chapter {ch_num}")
chapter_bookmarks.append((pos, ch_num, ch_title))
print(f"\n Mapped {len(chapter_bookmarks)} chapters:")
for pos, num, title in chapter_bookmarks:
print(f" Chapter {num}: {title} (paragraph {pos})")
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
# Content starts after the bookmark line (skip p17 and p5 heading)
content_start = ch_pos + 1
# Content ends at next chapter bookmark or cbhc_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = cbhc_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "cbhc",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 5: Build chapter listing for intro.json
chapter_listing = [
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
]
return {
"chapters": chapters,
"chapter_listing": chapter_listing,
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
"""Write data as formatted JSON to filepath, creating directories as needed."""
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", "others-before-1977.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "cbhc")
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 Śrī Caitanya-bhāgavata Commentary...\n")
result = extract_cbhc(soup)
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "cbhc",
"title": "Śrī Caitanya-bhāgavata Commentary",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": None,
"description": (
"Commentary on Śrī Caitanya-bhāgavata by Vṛndāvana dāsa Ṭhākura "
"— covering the first four chapters of Lord Caitanya's pastimes."
),
"totalChapters": 4,
"chapters": chapter_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write chapters ---
print(f"\n--- Chapters ---")
for ch in chapters:
ch_num_str = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}"
)
# --- Summary ---
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Chapters: {len(chapters)}")
for ch in chapters:
print(
f" Ch {ch['chapter']}: {len(ch['paragraphs'])} paragraphs — {ch['title']}"
)
print(f" Total paragraphs: {total_chapter_paras}")
if chapters:
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
for ch in chapters:
ch_num = ch["chapter"]
print(f"\n--- Sample: Chapter {ch_num} ({ch['title']}) ---")
for i, p in enumerate(ch["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(ch["paragraphs"]) > 3:
print(f" ... ({len(ch['paragraphs']) - 3} more paragraphs)")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 4 chapters present
expected_chapters = set(range(1, 5))
actual_chapters = set(ch["chapter"] for ch in chapters)
missing = expected_chapters - actual_chapters
extra = actual_chapters - expected_chapters
if missing:
print(f" ⚠ Missing chapters: {sorted(missing)}")
if extra:
print(f" ⚠ Extra chapters: {sorted(extra)}")
if not missing and not extra:
print(f" ✅ All 4 chapters present")
# Check empty chapters
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
if empty_chapters:
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
else:
print(f" ✅ No empty chapters")
# Check chapter 1 has "(End Chapter One)"
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"])
if "End Chapter One" in ch1_text:
print(f" ✅ '(End Chapter One)' end marker found in Chapter 1")
else:
print(f" ⚠ '(End Chapter One)' end marker NOT found in Chapter 1")
# Check chapter 2 mentions Lord Caitanya's appearance
if len(chapters) >= 2:
ch2_text = " ".join(chapters[1]["paragraphs"])
if "appearance" in ch2_text.lower() or "born" in ch2_text.lower():
print(f" ✅ Chapter 2 contains appearance/birth content")
else:
print(f" ⚠ Chapter 2 may not contain appearance content")
# Check for content leak from next work (Bhagaväner kathä)
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"])
if "Original Documents" in all_text:
print(f" ⚠ Content from next work (Bhagaväner kathä) may have leaked!")
else:
print(f" ✅ No content leak from next work detected")
# Check last chapter ends cleanly
if chapters:
last_ch = chapters[-1]
last_para = last_ch["paragraphs"][-1] if last_ch["paragraphs"] else ""
last_preview = re.sub(r"<[^>]+>", "", last_para)[:100]
print(f' ℹ Last paragraph of Ch {last_ch["chapter"]}: "{last_preview}..."')
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+675
View File
@@ -0,0 +1,675 @@
#!/usr/bin/env python3
"""Extract five "Essays on Śrī Caitanya-caritāmṛta" from others-before-1977.html.
Essay 1: "The Spiritual Masters" (cc-essays, order 1, 1960)
Essay 2: "The Absolute Truth" (cc-essays, order 2, 1957)
Essay 3: "The Causes of Lord Caitanya's Incarnation" (cc-essays, order 3, 1957)
Essay 4: "The Confidential Reasons for Lord Caitanya's Appearance" (cc-essays, order 4, 1957)
Essay 5: "The Glories of Lord Nityānanda" (cc-essays, order 5, 1957)
All five are plain English (NO ScaGoudy decoding needed).
Output:
lib/jsons/essays/the-spiritual-masters.json
lib/jsons/essays/the-absolute-truth.json
lib/jsons/essays/causes-of-lord-caitanyas-incarnation.json
lib/jsons/essays/confidential-reasons-for-lord-caitanyas-appearance.json
lib/jsons/essays/glories-of-lord-nityananda.json
Usage:
python3 scripts/cc_essays_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_css_class(tag: Tag) -> str:
"""Get the first CSS class of a tag, or empty string."""
classes = tag.get("class", [])
return classes[0] if classes else ""
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty or whitespace-only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
def clean_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, strip span class artifacts, preserve <i>, <b>, <br>."""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes (RTF conversion artifacts) but keep their content
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Remove any remaining bare <span> or </span> tags without classes
inner = re.sub(r"</?span[^>]*>", "", inner)
# Clean up Apple-tab-span leftovers (tab characters)
inner = inner.replace("\t", " ")
# Normalize whitespace (but preserve <br> tags)
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def write_json(data, filepath):
"""Write data as formatted JSON to filepath, creating directories as needed."""
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 is_divider(tag: Tag) -> bool:
"""Check if a p9 tag is just a divider line (underscores) or a page header."""
text = tag.get_text().strip()
# Dividers are lines of underscores
if re.match(r"^_+$", text):
return True
return False
def is_page_header(tag: Tag) -> bool:
"""Check if a p9 tag is a page header like 'Essays on Shri Chaitanya Charitamrita Page 30'."""
text = tag.get_text().strip()
if re.search(r"Page\s+\d+", text):
return True
return False
def is_editor_note(tag: Tag) -> bool:
"""Check if a paragraph is an editor note like '[This file has been proofread...]'."""
text = tag.get_text().strip()
if text.startswith("[") and (
"proofread" in text.lower() or "—Ed" in text or "- Ed" in text or ".—Ed" in text
):
return True
return False
# ---------------------------------------------------------------------------
# Essay configurations
# ---------------------------------------------------------------------------
AUTHOR = "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda"
ESSAYS = [
{
"slug": "the-spiritual-masters",
"title": "The Spiritual Masters",
"author": AUTHOR,
"year": 1960,
"group": "cc-essays",
"order": 1,
},
{
"slug": "the-absolute-truth",
"title": "The Absolute Truth",
"author": AUTHOR,
"year": 1957,
"group": "cc-essays",
"order": 2,
},
{
"slug": "causes-of-lord-caitanyas-incarnation",
"title": "The Causes of Lord Caitanya's Incarnation",
"author": AUTHOR,
"year": 1957,
"group": "cc-essays",
"order": 3,
},
{
"slug": "confidential-reasons-for-lord-caitanyas-appearance",
"title": "The Confidential Reasons for Lord Caitanya's Appearance",
"author": AUTHOR,
"year": 1957,
"group": "cc-essays",
"order": 4,
},
{
"slug": "glories-of-lord-nityananda",
"title": "The Glories of Lord Nityānanda",
"author": AUTHOR,
"year": 1957,
"group": "cc-essays",
"order": 5,
},
]
# ---------------------------------------------------------------------------
# Extraction logic
# ---------------------------------------------------------------------------
def parse_all_paragraphs(html_path: str) -> list[Tag]:
"""Parse the entire HTML and return all <p> tags."""
with open(html_path, "r", encoding="utf-8") as f:
content = f.read()
soup = BeautifulSoup(content, "html.parser")
return list(soup.find_all("p"))
def find_tag_index(tags: list[Tag], start_from: int, predicate) -> int | None:
"""Find index of first tag matching predicate, starting from start_from."""
for i in range(start_from, len(tags)):
if predicate(tags[i]):
return i
return None
def extract_body_paragraphs(
tags: list[Tag], start_idx: int, end_idx: int, include_p9_content: bool = True
) -> list[str]:
"""Extract body paragraphs from tags[start_idx] to tags[end_idx] (exclusive).
Includes p6 paragraphs (main body) and optionally p9 paragraphs that
contain actual content (Sanskrit verses, section markers) but skips
p9 paragraphs that are dividers or page headers.
"""
paragraphs = []
body_classes = {"p6"}
if include_p9_content:
body_classes.add("p9")
for i in range(start_idx, end_idx):
tag = tags[i]
css = get_css_class(tag)
if css not in body_classes:
continue
if is_empty(tag):
continue
# For p9 tags, skip dividers and page headers
if css == "p9":
if is_divider(tag) or is_page_header(tag):
continue
html = clean_html_content(tag)
if html and html != "&nbsp;":
paragraphs.append(html)
return paragraphs
def extract_body_paragraphs_extended(
tags: list[Tag],
start_idx: int,
end_idx: int,
body_classes: set[str] | None = None,
skip_editor_notes: bool = False,
) -> list[str]:
"""Extract body paragraphs from tags[start_idx] to tags[end_idx] (exclusive).
Like extract_body_paragraphs but with configurable body classes and
optional editor-note skipping.
"""
if body_classes is None:
body_classes = {"p6"}
paragraphs = []
for i in range(start_idx, end_idx):
tag = tags[i]
css = get_css_class(tag)
if css not in body_classes:
continue
if is_empty(tag):
continue
# For p9 tags, skip dividers and page headers
if css == "p9":
if is_divider(tag) or is_page_header(tag):
continue
# Skip editor notes if requested
if skip_editor_notes and is_editor_note(tag):
continue
html = clean_html_content(tag)
if html and html != "&nbsp;":
paragraphs.append(html)
return paragraphs
def extract_essay_1(tags: list[Tag]) -> dict:
"""Extract 'The Spiritual Masters' essay.
Start: <p class="p8"><b>(The Spiritual Masters).</b></p>
Body: p6 and p9 paragraphs after start
End: Next <p class="p10"> (contains [BTG 1960])
"""
# Find the title tag
start_idx = find_tag_index(
tags,
0,
lambda t: get_css_class(t) == "p8" and "Spiritual Masters" in t.get_text(),
)
if start_idx is None:
raise ValueError("Could not find Essay 1 start marker (p8 'Spiritual Masters')")
# Find the end marker: next p10
end_idx = find_tag_index(tags, start_idx + 1, lambda t: get_css_class(t) == "p10")
if end_idx is None:
raise ValueError("Could not find Essay 1 end marker (p10)")
print(f" Essay 1: tags[{start_idx}] → tags[{end_idx}] (exclusive)")
# Body starts after the p8 title tag
paragraphs = extract_body_paragraphs(tags, start_idx + 1, end_idx)
essay = dict(ESSAYS[0])
essay["paragraphs"] = paragraphs
return essay
def extract_essay_2(tags: list[Tag]) -> dict:
"""Extract 'The Absolute Truth' essay.
Start: <p class="p9">(Second Part)</p>
Body: p6 paragraphs after start
End: Next <p class="p11"> after start
"""
# Find the start marker
start_idx = find_tag_index(
tags,
0,
lambda t: get_css_class(t) == "p9" and "(Second Part)" in t.get_text(),
)
if start_idx is None:
raise ValueError("Could not find Essay 2 start marker (p9 '(Second Part)')")
# Find the end marker: next p11
end_idx = find_tag_index(tags, start_idx + 1, lambda t: get_css_class(t) == "p11")
if end_idx is None:
raise ValueError("Could not find Essay 2 end marker (p11)")
print(f" Essay 2: tags[{start_idx}] → tags[{end_idx}] (exclusive)")
# Body starts after the "(Second Part)" header
paragraphs = extract_body_paragraphs(tags, start_idx + 1, end_idx)
essay = dict(ESSAYS[1])
essay["paragraphs"] = paragraphs
return essay
def extract_essay_3(tags: list[Tag]) -> dict:
"""Extract 'The Causes of Lord Caitanya's Incarnation' essay.
Start: <p class="p9"> containing "Essays on Shri Chaitanya Charitamrita. III"
Body: p6 paragraphs after start
End: Next <p class="p11"> after start.
The <p class="p9">____</p> divider is skipped.
But the <p class="p6">Thus ends the III part...</p> IS included.
"""
# Find the start marker — note the ". III" with period
start_idx = find_tag_index(
tags,
0,
lambda t: (
get_css_class(t) == "p9"
and "Essays on Shri Chaitanya Charitamrita. III" in t.get_text()
),
)
if start_idx is None:
raise ValueError(
"Could not find Essay 3 start marker (p9 'Essays on Shri Chaitanya Charitamrita. III')"
)
# Find the end marker: next p11
end_idx = find_tag_index(tags, start_idx + 1, lambda t: get_css_class(t) == "p11")
if end_idx is None:
raise ValueError("Could not find Essay 3 end marker (p11)")
print(f" Essay 3: tags[{start_idx}] → tags[{end_idx}] (exclusive)")
# Body starts after the header tag.
# We collect p6 paragraphs. The divider (p9 with underscores) is skipped
# automatically by extract_body_paragraphs. The "Thus ends the III part..."
# paragraph is a p6, so it will be included.
paragraphs = extract_body_paragraphs(tags, start_idx + 1, end_idx)
essay = dict(ESSAYS[2])
essay["paragraphs"] = paragraphs
return essay
def extract_essay_4(tags: list[Tag]) -> dict:
"""Extract 'The Confidential Reasons for Lord Caitanya's Appearance' essay.
Start: <p class="p30"> containing "4th Chapter"
Body: p6 paragraphs after start (skip editor note, skip p4/p5/p29/p30 headers)
End: Next <p class="p4"> after start (next chapter header "Ädi-lélä, Chapter 5")
"""
# Find the start marker: p30 containing "4th Chapter"
start_idx = find_tag_index(
tags,
0,
lambda t: get_css_class(t) == "p30" and "4th Chapter" in t.get_text(),
)
if start_idx is None:
raise ValueError("Could not find Essay 4 start marker (p30 '4th Chapter')")
# Find the end marker: next p4 after the start (next chapter header)
end_idx = find_tag_index(tags, start_idx + 1, lambda t: get_css_class(t) == "p4")
if end_idx is None:
raise ValueError("Could not find Essay 4 end marker (p4)")
print(f" Essay 4: tags[{start_idx}] → tags[{end_idx}] (exclusive)")
# Collect p6 paragraphs, skipping editor notes
paragraphs = extract_body_paragraphs_extended(
tags,
start_idx + 1,
end_idx,
body_classes={"p6"},
skip_editor_notes=True,
)
essay = dict(ESSAYS[3])
essay["paragraphs"] = paragraphs
return essay
def extract_essay_5(tags: list[Tag]) -> dict:
"""Extract 'The Glories of Lord Nityānanda' essay.
Start: <p class="p31"> containing "1.V" (i.e. "Shi Chaitanya Charitamrita 1.V")
Body: p6 and p34 paragraphs after start (skip editor note, skip p4/p5/p31 headers,
skip p9 divider underscores)
End: Next <p class="p35"> after start ("Easy Journey To Other Planets")
"""
# Find the start marker: p31 containing "1.V"
start_idx = find_tag_index(
tags,
0,
lambda t: get_css_class(t) == "p31" and "1.V" in t.get_text(),
)
if start_idx is None:
raise ValueError("Could not find Essay 5 start marker (p31 containing '1.V')")
# Find the end marker: next p35 after the start
end_idx = find_tag_index(tags, start_idx + 1, lambda t: get_css_class(t) == "p35")
if end_idx is None:
raise ValueError("Could not find Essay 5 end marker (p35)")
print(f" Essay 5: tags[{start_idx}] → tags[{end_idx}] (exclusive)")
# Collect p6 and p34 paragraphs, skipping editor notes and p9 dividers
paragraphs = extract_body_paragraphs_extended(
tags,
start_idx + 1,
end_idx,
body_classes={"p6", "p34"},
skip_editor_notes=True,
)
essay = dict(ESSAYS[4])
essay["paragraphs"] = paragraphs
return essay
# ---------------------------------------------------------------------------
# Quality checks
# ---------------------------------------------------------------------------
def quality_check(essay: dict, essay_num: int) -> None:
"""Run quality checks on an extracted essay."""
paragraphs = essay["paragraphs"]
title = essay["title"]
print(f"\n--- Quality Checks: Essay {essay_num} ({title}) ---")
# Check paragraph count is reasonable
if len(paragraphs) < 5:
print(f" ⚠ Only {len(paragraphs)} paragraphs (suspiciously low)")
elif len(paragraphs) > 150:
print(f" ⚠ {len(paragraphs)} paragraphs (suspiciously high)")
else:
print(f" ✅ {len(paragraphs)} paragraphs (reasonable)")
# Check for residual span class artifacts
span_count = sum(1 for p in paragraphs if 'class="' in p and "<span" in p)
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check key terms
all_text = " ".join(paragraphs)
if "Krishna" in all_text or "Chaitanya" in all_text:
print(f" ✅ 'Krishna'/'Chaitanya' found (plain English, as expected)")
else:
print(f" ⚠ Key terms not found")
# Check that editor notes were excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if plain.startswith("[") and ("proofread" in plain.lower() or "—Ed" in plain):
print(f" ⚠ Editor note leaked into paragraphs")
break
else:
print(f" ✅ No editor notes leaked into paragraphs")
# Check that divider lines were excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if re.match(r"^_+$", plain):
print(f" ⚠ Divider line leaked into paragraphs")
break
else:
print(f" ✅ No divider lines leaked into paragraphs")
# Essay-specific checks
if essay_num == 1:
if "spiritual master" in all_text.lower():
print(f" ✅ 'spiritual master' found in text")
else:
print(f" ⚠ 'spiritual master' not found")
# Check that reference line [BTG 1960] was excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if "BTG 1960" in plain and plain.startswith("["):
print(f" ⚠ Reference line '[BTG 1960]' leaked into paragraphs")
break
else:
print(f" ✅ Reference line properly excluded")
# Check that title was excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if (
plain == "(The Spiritual Masters)."
or plain == "(The Spiritual Masters)."
):
print(f" ⚠ Title line leaked into paragraphs")
break
else:
print(f" ✅ Title line properly excluded")
elif essay_num == 2:
if "impersonal Brahman" in all_text or "Absolute Truth" in all_text:
print(f" ✅ 'Absolute Truth' / 'impersonal Brahman' found")
else:
print(f" ⚠ Key terms for Essay 2 not found")
# Check "(Second Part)" header was excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if plain == "(Second Part)":
print(f" ⚠ Header '(Second Part)' leaked into paragraphs")
break
else:
print(f" ✅ Header '(Second Part)' properly excluded")
elif essay_num == 3:
if "incarnation" in all_text.lower() or "descent" in all_text.lower():
print(f" ✅ 'incarnation'/'descent' found")
else:
print(f" ⚠ Key terms for Essay 3 not found")
# Check that "Thus ends the III part..." is included
found_ending = False
for p in paragraphs:
if "Thus ends the III part" in p:
found_ending = True
break
if found_ending:
print(f" ✅ 'Thus ends the III part...' paragraph included")
else:
print(f" ⚠ 'Thus ends the III part...' paragraph missing")
elif essay_num == 4:
if "Radharani" in all_text or "confidential" in all_text.lower():
print(f" ✅ 'Radharani'/'confidential' found")
else:
print(f" ⚠ Key terms for Essay 4 not found")
# Check that the summary paragraph is included (last paragraph)
found_summary = False
for p in paragraphs:
if "author then summarize" in p.lower():
found_summary = True
break
if found_summary:
print(f" ✅ Summary paragraph ('The author then summarize...') included")
else:
print(f" ⚠ Summary paragraph missing")
# Check that the first body paragraph mentions "three principal purposes"
if paragraphs:
if "three principal purposes" in paragraphs[0]:
print(f" ✅ First paragraph mentions 'three principal purposes'")
else:
print(f" ⚠ First paragraph doesn't mention 'three principal purposes'")
# Check that p4/p5/p29/p30 header lines were excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if "4th Chapter" in plain and len(plain) < 50:
print(f" ⚠ Header '4th Chapter' leaked into paragraphs")
break
else:
print(f" ✅ Header lines properly excluded")
elif essay_num == 5:
if "Nityananda" in all_text:
print(f" ✅ 'Nityananda' found")
else:
print(f" ⚠ 'Nityananda' not found")
# Check that the final p34 description paragraph is included
if paragraphs:
last_plain = re.sub(r"<[^>]+>", "", paragraphs[-1]).strip()
if (
"lusture" in last_plain
or "turban" in last_plain
or "wonderful feature" in last_plain
):
print(f" ✅ Final description paragraph (p34) included")
else:
print(f" ⚠ Final description paragraph (p34) may be missing")
print(f" Last paragraph preview: {last_plain[:80]}...")
# Check that the first body paragraph mentions "5th chapter"
if paragraphs:
if "5th chapter" in paragraphs[0].lower():
print(f" ✅ First paragraph mentions '5th chapter'")
else:
print(f" ⚠ First paragraph doesn't mention '5th chapter'")
# Check that p31 header was excluded
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
if "Shi Chaitanya Charitamrita 1.V" in plain:
print(
f" ⚠ Header 'Shi Chaitanya Charitamrita 1.V' leaked into paragraphs"
)
break
else:
print(f" ✅ Header lines properly excluded")
# Sample output
print(f"\n First paragraph (preview):")
if paragraphs:
preview = re.sub(r"<[^>]+>", "", paragraphs[0])[:120]
print(f" {preview}...")
print(f" Last paragraph (preview):")
if paragraphs:
last = re.sub(r"<[^>]+>", "", paragraphs[-1])[:120]
print(f" {last}...")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "others-before-1977.html")
output_dir = os.path.join(base_dir, "lib", "jsons", "essays")
if not os.path.exists(html_path):
print(f"ERROR: {html_path} not found!")
sys.exit(1)
print(f"Reading {html_path}...")
tags = parse_all_paragraphs(html_path)
print(f" Parsed {len(tags)} <p> tags total\n")
# Extract all five essays
print("Locating essays...")
essay_1 = extract_essay_1(tags)
essay_2 = extract_essay_2(tags)
essay_3 = extract_essay_3(tags)
essay_4 = extract_essay_4(tags)
essay_5 = extract_essay_5(tags)
all_essays = [essay_1, essay_2, essay_3, essay_4, essay_5]
# Write JSON files
print(f"\nWriting JSON files...")
for essay in all_essays:
output_path = os.path.join(output_dir, f"{essay['slug']}.json")
write_json(essay, output_path)
print(f" Written: {output_path}")
# Quality checks
for i, essay in enumerate(all_essays, 1):
quality_check(essay, i)
# Summary
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
for i, essay in enumerate(all_essays, 1):
print(
f" Essay {i}: {essay['title']}"
f" ({len(essay['paragraphs'])} paragraphs, {essay['year']})"
)
print(
f" slug={essay['slug']}, group={essay['group']}, order={essay['order']}"
)
print(f"{'=' * 60}")
print(f" Total essays: {len(all_essays)}")
print(f" Total paragraphs: {sum(len(e['paragraphs']) for e in all_essays)}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+688
View File
@@ -0,0 +1,688 @@
#!/usr/bin/env python3
"""Extract The Jaladuta Diary (1965) from others-before-1977.html.
Each diary day entry becomes its own chapter file.
Chapters are grouped by month in the intro.json metadata.
Structure:
- Preface and Introduction are standalone chapters (slugs "preface", "introduction")
- Day entries use slugs like "apr-3", "may-2", "aug-13", "sep-1", etc.
- The "Memoranda" entry at the end uses slug "memoranda"
- Months are parent groups in intro.json (non-clickable headings)
Output:
lib/jsons/journals/jaladuta-diary/intro.json
lib/jsons/journals/jaladuta-diary/chapters/preface.json
lib/jsons/journals/jaladuta-diary/chapters/introduction.json
lib/jsons/journals/jaladuta-diary/chapters/apr-3.json
lib/jsons/journals/jaladuta-diary/chapters/apr-5.json
...
lib/jsons/journals/jaladuta-diary/chapters/nov-28.json
lib/jsons/journals/jaladuta-diary/chapters/memoranda.json
Usage:
python3 scripts/jaladuta_diary_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"Ì": "Ṅ",
"ï": "ñ",
"Ï": "Ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def get_css_class(tag: Tag) -> str:
classes = tag.get("class", [])
return classes[0] if classes else ""
def inner_html(tag: Tag) -> str:
"""Get inner HTML of a tag, decode ScaGoudy, clean up spans."""
html = "".join(str(child) for child in tag.children)
html = decode_scagoudy(html)
# <span class="s3">text</span> → <i>text</i>
html = re.sub(
r'<span[^>]*class="s3"[^>]*>(.*?)</span>', r"<i>\1</i>", html, flags=re.DOTALL
)
# <span class="s5">...</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s5"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s13">...</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s1[0-9]+"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s23">...</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s2[0-9]+"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# Remove any remaining spans but keep content
html = re.sub(r"<span[^>]*>(.*?)</span>", r"\1", html, flags=re.DOTALL)
# Clean up Apple-tab-span leftovers
html = html.replace('<span class="Apple-tab-span">\t</span>', "\t")
# Collapse multiple spaces (but not around <br>)
html = re.sub(r"[ \t]+", " ", html)
html = html.strip()
# Remove leading <br> tags
html = re.sub(r"^(<br\s*/?>[\s]*)+", "", html)
return html
def write_json(data: dict, path: str):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
# ---------------------------------------------------------------------------
# Month abbreviation helpers
# ---------------------------------------------------------------------------
MONTH_ABBR = {
"april": "apr",
"may": "may",
"june": "jun",
"july": "jul",
"august": "aug",
"september": "sep",
"october": "oct",
"november": "nov",
}
MONTH_ORDER = ["apr", "may", "jun", "jul", "aug", "sep", "oct", "nov"]
DAY_NAMES = {
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
}
def parse_day_header(bold_text: str):
"""Parse the bold header of a diary entry to extract day name and date number.
Handles formats like:
"SATURDAY 3"
"13 FRIDAY"
"SATURDAY" (date-less, like Aug 14)
"MEMORANDA"
Returns (day_name, date_number) or (None, None) if not a day entry.
"""
text = bold_text.strip().split("\n")[0].strip()
text = re.sub(r"<br\s*/?>", " ", text).strip()
if text.upper() == "MEMORANDA":
return ("MEMORANDA", None)
# Try "DAY NUMBER" pattern
m = re.match(r"([A-Za-z]+)\s+(\d+)", text)
if m:
day = m.group(1).upper()
num = int(m.group(2))
if day in {d.upper() for d in DAY_NAMES}:
return (day, num)
# Try "NUMBER DAY" pattern
m = re.match(r"(\d+)\s+([A-Za-z]+)", text)
if m:
num = int(m.group(1))
day = m.group(2).upper()
if day in {d.upper() for d in DAY_NAMES}:
return (day, num)
# Try just "DAY" (no number — like SATURDAY 14 where 14 is on next line)
word = text.split()[0].upper() if text.split() else ""
if word in {d.upper() for d in DAY_NAMES}:
# Try to find number after
rest = text[len(word) :].strip()
m2 = re.match(r"(\d+)", rest)
if m2:
return (word, int(m2.group(1)))
return (word, None)
return (None, None)
# ---------------------------------------------------------------------------
# Main extraction
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "others-before-1977.html")
output_dir = os.path.join(base_dir, "lib", "jsons", "journals", "jaladuta-diary")
chapters_dir = os.path.join(output_dir, "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:
content = f.read()
soup = BeautifulSoup(content, "html.parser")
all_tags = soup.find_all("p")
print(f" Parsed {len(all_tags)} <p> tags total")
# Find the Jaladuta Diary section boundaries
start_idx = None
end_idx = None
for i, tag in enumerate(all_tags):
text = tag.get_text().strip()
cls = get_css_class(tag)
if cls == "p35" and "Jaladuta Diary" in text:
start_idx = i
if start_idx is not None and i > start_idx:
# End at "Prayers Written by Śrīla Prabhupāda aboard the Jaladuta" or the next p4 bookmark after JD
if cls == "p4" and "JD 8" in text:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find Jaladuta Diary start")
sys.exit(1)
if end_idx is None:
# Fallback: use the memoranda end
for i, tag in enumerate(all_tags):
if (
i > start_idx
and "MEMORANDA" in tag.get_text()
and get_css_class(tag) == "p110"
):
end_idx = i + 1
break
print(f" Section: tags[{start_idx}] → tags[{end_idx}]")
section_tags = all_tags[start_idx:end_idx]
# ---------------------------------------------------------------------------
# Phase 1: Extract Preface and Introduction
# ---------------------------------------------------------------------------
preface_paragraphs = []
intro_paragraphs = []
current_section = None # "preface" | "introduction" | None
# Also track where months/diary entries begin
diary_start_idx = None
for i, tag in enumerate(section_tags):
cls = get_css_class(tag)
text = tag.get_text().strip()
# Detect Preface header
if cls == "p3" and text == "Preface":
current_section = "preface"
continue
if cls == "p153": # bookmark
continue
# Detect Introduction header
if cls in ("p11", "p157") and "Introduction" in text:
if cls == "p157":
current_section = "introduction"
continue
elif cls == "p11":
current_section = "introduction"
continue
# Detect month start (diary entries begin)
if cls == "p11" and re.match(r"JD \d+:", text):
diary_start_idx = i
current_section = None
break
if cls == "p29":
# Bold month header like "APRIL 1965" — this is diary territory
diary_start_idx = i
current_section = None
break
# Collect preface/intro paragraphs
if current_section == "preface" and cls in ("p154", "p155", "p156"):
html = inner_html(tag)
if html:
preface_paragraphs.append(html)
elif current_section == "introduction" and cls in ("p154", "p155", "p156"):
html = inner_html(tag)
if html:
intro_paragraphs.append(html)
print(f"\n Preface: {len(preface_paragraphs)} paragraphs")
print(f" Introduction: {len(intro_paragraphs)} paragraphs")
# ---------------------------------------------------------------------------
# Phase 2: Extract diary entries grouped by month
# ---------------------------------------------------------------------------
# Walk through remaining tags, tracking current month
current_month = None # "apr", "may", etc.
current_month_full = None # "April 1965", etc.
# Section headers that appear mid-month
section_headers = {} # Maps tag index to header text
# All day entries: list of { month, day, date, slug, title, paragraphs }
day_entries = []
current_entry = None
MONTH_MAP = {
"APRIL": ("apr", "April 1965"),
"MAY": ("may", "May 1965"),
"JUNE": ("jun", "June 1965"),
"JULY": ("jul", "July 1965"),
"AUGUST": ("aug", "August 1965"),
"SEPTEMBER": ("sep", "September 1965"),
"OCTOBER": ("oct", "October 1965"),
"NOVEMBER": ("nov", "November 1965"),
}
def finalize_entry():
nonlocal current_entry
if current_entry and current_entry["paragraphs"]:
day_entries.append(current_entry)
current_entry = None
diary_tags = section_tags[diary_start_idx:] if diary_start_idx else []
for i, tag in enumerate(diary_tags):
cls = get_css_class(tag)
text = tag.get_text().strip()
# Skip bookmarks
if cls in ("p11", "p4", "p39", "p153"):
continue
# Detect month headers (p29 bold)
if cls == "p29":
html = inner_html(tag)
# Check if this is a month header like "APRIL 1965" or "MAY 1965"
for month_key, (abbr, full) in MONTH_MAP.items():
if month_key in text.upper():
finalize_entry()
current_month = abbr
current_month_full = full
break
else:
# Could be a section header like "Verse 1." — skip if not month
# Or it could be a section header within a month
pass
continue
# Detect section headers (p68 class — "The Journey", "America")
if cls == "p68":
finalize_entry()
html = inner_html(tag)
# Create a section-header entry
if html and current_month:
section_entry = {
"month": current_month,
"month_full": current_month_full,
"day": None,
"date": None,
"slug": None,
"title": None,
"paragraphs": [f"<b>{re.sub('<[^>]+>', ' ', html).strip()}</b>"],
"is_section_header": True,
}
# Extract title text
plain = re.sub("<[^>]+>", " ", html)
plain = re.sub(r"\s+", " ", plain).strip()
section_entry["title"] = plain
day_entries.append(section_entry)
continue
# Detect diary entries (p110 class)
if cls == "p110":
finalize_entry()
html = inner_html(tag)
if not html:
continue
# Parse the bold header
b_tag = tag.find("b")
if b_tag:
bold_text = b_tag.get_text().strip()
day_name, date_num = parse_day_header(bold_text)
else:
day_name, date_num = (None, None)
if day_name == "MEMORANDA":
current_entry = {
"month": current_month,
"month_full": current_month_full,
"day": "MEMORANDA",
"date": None,
"slug": "memoranda",
"title": "Memoranda",
"paragraphs": [html],
"is_section_header": False,
}
elif day_name and date_num and current_month:
slug = f"{current_month}-{date_num}"
title = f"{day_name.capitalize()} {date_num}"
current_entry = {
"month": current_month,
"month_full": current_month_full,
"day": day_name,
"date": date_num,
"slug": slug,
"title": title,
"paragraphs": [html],
"is_section_header": False,
}
elif day_name and current_month:
# Day without date number (rare — like "SATURDAY" alone on Aug 14)
# Try to infer from context or just skip
slug = f"{current_month}-{day_name.lower()}"
title = day_name.capitalize()
current_entry = {
"month": current_month,
"month_full": current_month_full,
"day": day_name,
"date": None,
"slug": slug,
"title": title,
"paragraphs": [html],
"is_section_header": False,
}
else:
# Can't parse — treat as continuation or standalone
current_entry = {
"month": current_month,
"month_full": current_month_full,
"day": None,
"date": None,
"slug": None,
"title": text[:50],
"paragraphs": [html],
"is_section_header": False,
}
continue
# Detect p12 paragraphs (continuation text in some entries like Sep 16)
if cls == "p12" and current_entry:
html = inner_html(tag)
if html:
current_entry["paragraphs"].append(html)
continue
# p155 (source citations within diary section)
if cls == "p155" and current_entry:
html = inner_html(tag)
if html:
current_entry["paragraphs"].append(html)
continue
finalize_entry()
print(f"\n Total day entries extracted: {len(day_entries)}")
# Count by month
month_counts = {}
for entry in day_entries:
m = entry["month"]
month_counts[m] = month_counts.get(m, 0) + 1
for m in MONTH_ORDER:
if m in month_counts:
print(f" {m}: {month_counts[m]} entries")
# ---------------------------------------------------------------------------
# Phase 3: Fix up entries — handle dateless entries, dedup slugs
# ---------------------------------------------------------------------------
# Some entries have no date — try to fix known cases
# Aug "SATURDAY" without number is Aug 14 (follows Aug 13 FRIDAY)
for i, entry in enumerate(day_entries):
if entry.get("is_section_header"):
continue
if entry["date"] is None and entry["day"] and entry["day"] != "MEMORANDA":
# Try to infer from previous entry
if i > 0:
prev = day_entries[i - 1]
if prev["date"] and prev["month"] == entry["month"]:
inferred = prev["date"] + 1
entry["date"] = inferred
entry["slug"] = f"{entry['month']}-{inferred}"
entry["title"] = f"{entry['day'].capitalize()} {inferred}"
print(
f" Inferred date: {entry['slug']} (from previous {prev['slug']})"
)
# Ensure unique slugs
seen_slugs = set()
for entry in day_entries:
if entry.get("is_section_header"):
continue
slug = entry["slug"]
if slug and slug in seen_slugs:
# Append suffix
suffix = 2
while f"{slug}-{suffix}" in seen_slugs:
suffix += 1
entry["slug"] = f"{slug}-{suffix}"
print(f" Deduped slug: {slug} → {entry['slug']}")
if slug:
seen_slugs.add(entry["slug"])
# Merge section headers into the next entry's paragraphs (prepend)
merged_entries = []
pending_header = None
for entry in day_entries:
if entry.get("is_section_header"):
pending_header = entry["paragraphs"][0]
continue
if pending_header:
entry["paragraphs"] = [pending_header] + entry["paragraphs"]
pending_header = None
merged_entries.append(entry)
day_entries = merged_entries
# ---------------------------------------------------------------------------
# Phase 4: Build intro.json with hierarchical month groups
# ---------------------------------------------------------------------------
# Assign chapter numbers: preface=1, introduction=2, then day entries sequentially
chapter_num = 1
all_chapters = []
# Preface
preface_chapter = {
"slug": "preface",
"title": "Preface",
"number": chapter_num,
}
all_chapters.append(preface_chapter)
chapter_num += 1
# Introduction
intro_chapter = {
"slug": "introduction",
"title": "Introduction",
"number": chapter_num,
}
all_chapters.append(intro_chapter)
chapter_num += 1
# Group day entries by month
months_data = [] # List of { month, title, subChapters: [...] }
current_month_group = None
for entry in day_entries:
month = entry["month"]
month_full = entry["month_full"]
if current_month_group is None or current_month_group["month"] != month:
current_month_group = {
"month": month,
"title": month_full,
"subChapters": [],
}
months_data.append(current_month_group)
entry["number"] = chapter_num
current_month_group["subChapters"].append(
{
"slug": entry["slug"],
"title": entry["title"],
}
)
chapter_num += 1
# Build the intro.json chapters array
# Front matter chapters (preface, introduction) are top-level
# Month groups use the hierarchical sub-chapter pattern
intro_chapters = []
# Add preface and introduction as simple chapters
intro_chapters.append({"slug": "preface", "title": "Preface"})
intro_chapters.append({"slug": "introduction", "title": "Introduction"})
# Add month groups
for mg in months_data:
intro_chapters.append(
{
"title": mg["title"],
"subChapters": mg["subChapters"],
}
)
intro_json = {
"book": "jaladuta-diary",
"title": "The Jaladuta Diary, 1965 — Journey to the U.S.A.",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1965,
"description": "Śrīla Prabhupāda's personal diary kept during his historic voyage from Calcutta to New York aboard the cargo ship Jaladuta in 1965.",
"totalChapters": chapter_num - 1,
"chapters": intro_chapters,
}
# ---------------------------------------------------------------------------
# Phase 5: Write JSON files
# ---------------------------------------------------------------------------
# Clean output directory
if os.path.exists(chapters_dir):
for f in os.listdir(chapters_dir):
os.remove(os.path.join(chapters_dir, f))
# Write intro.json
write_json(intro_json, os.path.join(output_dir, "intro.json"))
print(f"\n Written: intro.json ({len(intro_chapters)} top-level entries)")
# Write preface
write_json(
{
"book": "jaladuta-diary",
"chapter": 1,
"title": "Preface",
"type": "preface",
"slug": "preface",
"paragraphs": preface_paragraphs,
},
os.path.join(chapters_dir, "preface.json"),
)
print(f" Written: preface.json ({len(preface_paragraphs)} paragraphs)")
# Write introduction
write_json(
{
"book": "jaladuta-diary",
"chapter": 2,
"title": "Introduction",
"type": "introduction",
"slug": "introduction",
"paragraphs": intro_paragraphs,
},
os.path.join(chapters_dir, "introduction.json"),
)
print(f" Written: introduction.json ({len(intro_paragraphs)} paragraphs)")
# Write day entries
ch_num = 3
for entry in day_entries:
slug = entry["slug"]
if not slug:
print(f" ⚠ Skipping entry with no slug: {entry.get('title', '?')}")
continue
chapter_data = {
"book": "jaladuta-diary",
"chapter": ch_num,
"title": entry["title"],
"type": "chapter",
"slug": slug,
"paragraphs": entry["paragraphs"],
}
write_json(chapter_data, os.path.join(chapters_dir, f"{slug}.json"))
ch_num += 1
total_files = 2 + len([e for e in day_entries if e.get("slug")])
print(f"\n Total chapter files written: {total_files}")
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Preface: {len(preface_paragraphs)} paragraphs")
print(f" Introduction: {len(intro_paragraphs)} paragraphs")
print(f" Day entries: {len(day_entries)}")
for mg in months_data:
print(f" {mg['title']}: {len(mg['subChapters'])} entries")
for sc in mg["subChapters"]:
print(f" - {sc['slug']}: {sc['title']}")
print(f" Total chapters: {ch_num - 1}")
print(f"{'=' * 60}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env python3
"""Extract Message of Godhead (1955) from others-before-1977.html.
The book has 1 intro segment (front-pages) and 3 chapters:
1. Message of Godhead (Introductory)
2. Knowledge Transcendental
3. Work with Transcendental Effect (Karma Yoga)
Output:
lib/jsons/books/mog/intro.json — Book metadata
lib/jsons/books/mog/intro/*.json — Intro segments (front-pages)
lib/jsons/books/mog/chapters/*.json — Chapters (01–03)
Usage:
python3 scripts/mog_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, preserve formatting tags, strip spans."""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Clean up Apple-tab-span leftovers (tab characters)
inner = inner.replace("\t", " ")
# Normalize whitespace
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
"""Get plain text from a tag."""
return tag.get_text().strip()
def get_css_class(tag: Tag) -> str:
"""Get the first CSS class of a tag, or empty string."""
classes = tag.get("class", [])
return classes[0] if classes else ""
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty or whitespace-only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# CSS class categorization for MOG
# ---------------------------------------------------------------------------
# Front page classes
FRONT_TITLE_CLASS = "p3" # Book title header (bold)
FRONT_INFO_CLASS = "p19" # Author/address info
FRONT_DATE_CLASS = "p15" # Date line ("From the 30/8/55")
# Chapter heading classes
CHAPTER_BOOKMARK_CLASS = (
"p11" # Chapter bookmark headers (like "MESSAGE OF GODHEAD (Introductory)")
)
CHAPTER_TITLE_CLASS = "p5" # Chapter title (bold, duplicate of p11 — skip)
# Body paragraph classes
BODY_CLASSES = frozenset(["p6", "p38", "p44", "p45", "p46", "p47"])
# Classes to skip entirely (empty/decorative/break)
SKIP_CLASSES = frozenset(["p43", "p24"])
# Boundary detection: classes that mark the start of a different work after MOG
NEXT_WORK_BOUNDARY_CLASSES = frozenset(["p2", "p3"])
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_mog_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the MOG content.
Start: <p class="p3"> containing "Message of Godhead"
End: next <p class="p2"> or <p class="p3"> that starts a different book,
or <p class="p6"> containing "Bhakti-rasāmṛta-sindhu" (the next work)
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = decode_text(p)
# Find start: p3 containing "Message of Godhead"
if start_idx is None:
if cls == "p2" and "Message of Godhead" in text:
# The p2 is the TOC entry; the actual start is the next p3
start_idx = i
continue
continue
# Find end: look for the next work boundary
# The p6 containing "Bhakti-rasāmṛta-sindhu" starts a different work
if cls == "p6" and "Bhakti-ras" in text and "sindhu" in text:
end_idx = i
break
# Also check for p2 that starts a different book section
if cls == "p2" and start_idx is not None and i > start_idx + 5:
text_lower = text.lower()
if "message of godhead" not in text_lower:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Message of Godhead' in others-before-1977.html!")
sys.exit(1)
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all MOG chapter bookmark tags (p11) in the MOG range.
Returns list of (paragraph_index, chapter_title).
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_BOOKMARK_CLASS:
text = decode_text(p).strip()
if text:
bookmarks.append((i, text))
return bookmarks
# ---------------------------------------------------------------------------
# Paragraph extraction
# ---------------------------------------------------------------------------
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (front pages or chapter body).
For front pages: collect p3, p19, p15 lines as paragraphs.
For chapters: collect body paragraphs (p6, p38, p44, p45, p46, p47).
Always skip p43, p24 (empty/break), p11 (bookmark), p5 (duplicate heading).
"""
result = []
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 known empty/break classes
if cls in SKIP_CLASSES:
continue
# Skip chapter bookmark headers (p11) — used for boundary detection only
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip chapter title duplicate (p5)
if cls == CHAPTER_TITLE_CLASS:
continue
# Skip p2 (TOC entry at start)
if cls == "p2":
continue
if is_front_pages:
# For front pages, collect title, author/address, and date lines
if cls in (FRONT_TITLE_CLASS, FRONT_INFO_CLASS, FRONT_DATE_CLASS):
html = decode_html_content(p)
if html:
result.append(html)
continue
# Also include any other non-skip class if we encounter it
# (shouldn't happen much for front pages, but be permissive)
if cls in BODY_CLASSES:
# Body paragraph before first chapter = still front pages
html = decode_html_content(p)
if html:
result.append(html)
continue
else:
# For chapter body, collect all body paragraph classes
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# p19 can appear inside chapters (e.g., "(Devanāgarī)" in chapter 2)
if cls == FRONT_INFO_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page class inside chapter body (shouldn't happen, but be safe)
if cls == FRONT_TITLE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Fallback: try to extract content from unknown classes
html = decode_html_content(p)
if html:
result.append(html)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_mog(soup: BeautifulSoup) -> dict:
"""Extract the entire Message of Godhead from the parsed HTML.
Returns a dict with:
"intro_segments": list of intro segment dicts
"chapters": list of chapter dicts
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find MOG boundaries
mog_start, mog_end = find_mog_boundaries(all_paragraphs)
print(f" MOG content: paragraphs {mog_start} to {mog_end}")
print(f" Total paragraphs in range: {mog_end - mog_start}")
# Step 2: Find all chapter bookmarks (p11)
bookmarks = find_chapter_bookmarks(all_paragraphs, mog_start, mog_end)
print(f" Found {len(bookmarks)} MOG chapter bookmarks:")
for pos, title in bookmarks:
print(f" Paragraph {pos}: {title}")
# Step 3: Map bookmarks to chapters with proper titles
# Expected: 3 chapters
# Map raw bookmark text (case-insensitive) to normalized titles
chapter_titles = {
"message of godhead": "Message of Godhead (Introductory)",
"knowledge transcendental": "Knowledge Transcendental",
"work with transcendental effect": "Work with Transcendental Effect (Karma Yoga)",
}
chapter_bookmarks = [] # (para_idx, chapter_num, normalized_title)
ch_num = 1
for pos, raw_title in bookmarks:
# Try to match known chapter titles (case-insensitive)
matched_title = None
raw_lower = raw_title.lower().strip()
for key, nice_title in chapter_titles.items():
if key in raw_lower:
matched_title = nice_title
break
if matched_title is None:
# Fallback: use the raw title with title case
matched_title = raw_title.strip()
chapter_bookmarks.append((pos, ch_num, matched_title))
ch_num += 1
print(f"\n Mapped {len(chapter_bookmarks)} chapters:")
for pos, num, title in chapter_bookmarks:
print(f" Chapter {num}: {title} (paragraph {pos})")
# Step 4: Extract front pages
# From MOG start to first chapter bookmark
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = mog_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, mog_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "mog",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 5: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
# Content starts after the bookmark line
# But we also need to skip the p5 duplicate heading that follows p11
content_start = ch_pos + 1
# Content ends at next chapter bookmark or mog_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = mog_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "mog",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 6: Build chapter listing for intro.json
chapter_listing = [
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
]
return {
"intro_segments": intro_segments,
"chapters": chapters,
"chapter_listing": chapter_listing,
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
"""Write data as formatted JSON to filepath, creating directories as needed."""
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", "others-before-1977.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "mog")
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 Message of Godhead (1955)...\n")
result = extract_mog(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "mog",
"title": "Message of Godhead",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1955,
"description": "An early handwritten work presenting the essence of Bhagavad-gītā's teachings on transcendental knowledge, karma-yoga, and the Personality of Godhead.",
"totalChapters": 3,
"chapters": chapter_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write intro segments ---
print(f"\n--- Intro Segments ---")
for seg in intro_segments:
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
write_json(seg, filepath)
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
)
# --- Write chapters ---
print(f"\n--- Chapters ---")
for ch in chapters:
ch_num_str = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}"
)
# --- Summary ---
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
total_paras = total_intro_paras + total_chapter_paras
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Intro segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Chapters: {len(chapters)}")
for ch in chapters:
print(
f" Ch {ch['chapter']}: {len(ch['paragraphs'])} paragraphs — {ch['title']}"
)
print(f" Total paragraphs: {total_paras}")
print(f" Intro: {total_intro_paras}")
print(f" Chapters: {total_chapter_paras}")
if chapters:
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
print(f"\n--- Sample: Front Pages ---")
if intro_segments:
front = intro_segments[0]
for i, p in enumerate(front["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}")
if chapters:
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
ch1 = chapters[0]
for i, p in enumerate(ch1["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 2:
print(f"\n--- Sample: Chapter 2 ({chapters[1]['title']}) ---")
ch2 = chapters[1]
for i, p in enumerate(ch2["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 3:
print(f"\n--- Sample: Chapter 3 ({chapters[2]['title']}) ---")
ch3 = chapters[2]
for i, p in enumerate(ch3["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 3 chapters present
expected_chapters = set(range(1, 4))
actual_chapters = set(ch["chapter"] for ch in chapters)
missing = expected_chapters - actual_chapters
extra = actual_chapters - expected_chapters
if missing:
print(f" ⚠ Missing chapters: {sorted(missing)}")
if extra:
print(f" ⚠ Extra chapters: {sorted(extra)}")
if not missing and not extra:
print(f" ✅ All 3 chapters present")
# Check intro segments
if intro_segments:
print(
f" ✅ Front pages segment present ({len(intro_segments[0]['paragraphs'])} paragraphs)"
)
else:
print(f" ⚠ Front pages segment missing!")
# Check empty chapters
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
if empty_chapters:
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
else:
print(f" ✅ No empty chapters")
# Check for "——The Author." in chapter 1
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"])
if "The Author" in ch1_text:
print(f" ✅ '——The Author.' attribution found in Chapter 1")
else:
print(f" ⚠ '——The Author.' attribution NOT found in Chapter 1")
# Check chapter 2 has "(Devanāgarī)" line
if len(chapters) >= 2:
ch2_text = " ".join(chapters[1]["paragraphs"])
if "Devan" in ch2_text:
print(f" ✅ '(Devanāgarī)' line found in Chapter 2")
else:
print(f" ℹ '(Devanāgarī)' line not found in Chapter 2 (may be normal)")
# Verify no Bhakti-rasāmṛta-sindhu content leaked in
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"])
if "Text 22" in all_text or "BHAKTI RASAMRITA" in all_text.upper():
print(f" ⚠ Content from Bhakti-rasāmṛta-sindhu may have leaked into MOG!")
else:
print(f" ✅ No content leak from next work detected")
# Check last chapter ends cleanly
if chapters:
last_ch = chapters[-1]
last_para = last_ch["paragraphs"][-1] if last_ch["paragraphs"] else ""
last_preview = re.sub(r"<[^>]+>", "", last_para)[:80]
print(f' ℹ Last paragraph of Ch {last_ch["chapter"]}: "{last_preview}..."')
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Extract "An Analysis of Bhagwat Geeta" from others-before-1977.html.
This document is a structured analysis organized into three numbered sections
(topics) with numbered points and verse references. It is plain English text
(no ScaGoudy decoding needed).
Source: public/html/others-before-1977.html
- Title: <p class="p116"><b>An Analysis of Bhagwat Geeta</b></p> (line ~15182)
- Section 1: "Mans eternal relation with Godhead" — 45 <p class="p6"> items
- Section 2: "Man's Duty Toward Godhead" — 34 <p class="p6"> items
- Section 3: "Man's ultimate necessity and perfection of life" — 3 <p class="p6"> items
- End boundary: <p class="p11">Solution of Present Crisises... (line ~15276)
CSS classes:
p116 — Title (bold)
p39 — Section headers ("1: Mans eternal relation with Godhead")
p5 — Duplicate section title (bold, with <span class="s3">) — SKIP
p6 — Body paragraphs (plain English)
p34 — Math calculation at the end — SKIP
p11 — Next document title (end boundary)
Output (prose book structure):
lib/jsons/other-documents/analysis-of-bhagavad-gita/
intro.json # metadata
chapters/
01.json # Chapter 1
02.json # Chapter 2
03.json # Chapter 3
Usage:
python3 scripts/od_analysis_bg_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
BOOK_CODE = "od-analysis-bg"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_css_class(tag: Tag) -> str:
"""Get the first CSS class of a tag, or empty string."""
classes = tag.get("class", [])
return classes[0] if classes else ""
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty or whitespace-only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
def clean_text(tag: Tag) -> str:
"""Extract inner HTML from a tag, strip span class artifacts, normalize whitespace."""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes (RTF conversion artifacts) but keep their content
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Remove any remaining bare <span> or </span> tags
inner = re.sub(r"</?span[^>]*>", "", inner)
# Remove <b> and </b> tags (not needed for body paragraphs)
inner = re.sub(r"</?b>", "", inner)
# Clean up Apple-tab-span leftovers (tab characters)
inner = inner.replace("\t", " ")
# Strip <br> tags
inner = re.sub(r"<br\s*/?>", " ", inner)
# Normalize whitespace
inner = re.sub(r"\s+", " ", inner).strip()
# Fix HTML entities
inner = inner.replace("&amp;", "&")
return inner
def write_json(data, filepath):
"""Write data as formatted JSON to filepath, creating directories as needed."""
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" ✅ Wrote {filepath}")
# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def extract_chapters(soup: BeautifulSoup) -> list[dict]:
"""Extract the chapter data from 'An Analysis of Bhagwat Geeta'."""
# Find the title tag: <p class="p116"><b>An Analysis of Bhagwat Geeta</b></p>
title_tag = None
for p in soup.find_all("p", class_="p116"):
text = p.get_text().strip()
if "Analysis of Bhagwat Geeta" in text:
title_tag = p
break
if not title_tag:
print("❌ Could not find title 'An Analysis of Bhagwat Geeta'")
sys.exit(1)
print(f" 📍 Found title: {title_tag.get_text().strip()}")
# Collect all <p> tags after the title until we hit the end boundary
chapters = []
current_chapter = None
tag = title_tag.find_next_sibling()
while tag:
if not isinstance(tag, Tag) or tag.name != "p":
tag = tag.find_next_sibling()
continue
css = get_css_class(tag)
text = tag.get_text().strip()
# End boundary: next document starts
if css == "p11" and "Solution of Present Crisises" in text:
break
# Skip empty tags
if is_empty(tag):
tag = tag.find_next_sibling()
continue
# Section header: <p class="p39">1: Mans eternal relation with Godhead</p>
if css == "p39":
# Parse section number and title
match = re.match(r"(\d+):\s*(.+)", text)
if match:
num = int(match.group(1))
title = match.group(2).strip()
current_chapter = {
"number": num,
"title": title,
"paragraphs": [],
}
chapters.append(current_chapter)
print(f" 📖 Section {num}: {title}")
tag = tag.find_next_sibling()
continue
# Skip p5 (duplicate section title in bold with span)
if css == "p5":
tag = tag.find_next_sibling()
continue
# Skip p34 (math calculation at the end)
if css == "p34":
tag = tag.find_next_sibling()
continue
# Body paragraph: <p class="p6">
if css == "p6" and current_chapter is not None:
cleaned = clean_text(tag)
if cleaned:
current_chapter["paragraphs"].append(cleaned)
tag = tag.find_next_sibling()
continue
# Any other class — skip
tag = tag.find_next_sibling()
return chapters
# ---------------------------------------------------------------------------
# Quality check
# ---------------------------------------------------------------------------
def quality_check(chapters: list[dict]):
"""Validate the extracted chapters."""
print("\n🔍 Quality check:\n")
print(f" Chapters: {len(chapters)}")
assert len(chapters) == 3, f"Expected 3 chapters, got {len(chapters)}"
total_paragraphs = 0
for ch in chapters:
count = len(ch["paragraphs"])
total_paragraphs += count
print(f' Chapter {ch["number"]}: "{ch["title"]}" — {count} paragraphs')
# Spot-check first paragraph
if ch["paragraphs"]:
first = ch["paragraphs"][0]
print(f" First: {first[:80]}...")
# Spot-check last paragraph
if len(ch["paragraphs"]) > 1:
last = ch["paragraphs"][-1]
print(f" Last: {last[:80]}...")
print(f"\n Total paragraphs: {total_paragraphs}")
# Expected counts (from manual inspection):
# Section 1: 45 items (numbered 1-45)
# Section 2: 34 items (numbered 1-34)
# Section 3: 3 items (numbered 1-3)
expected_total = 82 # 45 + 34 + 3
if total_paragraphs == expected_total:
print(f" ✅ Paragraph count matches expected: {expected_total}")
else:
print(f" ⚠️ Expected {expected_total} paragraphs, got {total_paragraphs}")
# This is a warning, not a hard failure — some items may have sub-items
# or the HTML structure may slightly differ
# Check that paragraphs are not empty
for ch in chapters:
for i, p in enumerate(ch["paragraphs"]):
assert p.strip(), f"Empty paragraph in chapter {ch['number']}, index {i}"
print("\n ✅ All checks passed!")
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_output(chapters: list[dict], output_dir: str):
"""Write intro.json and per-chapter JSON files."""
# Build intro.json
intro = {
"book": BOOK_CODE,
"title": "An Analysis of Bhagwat Geeta",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": None,
"description": "A structured analysis of Bhagavad-gītā organized into three topics with numbered points and verse references.",
"totalChapters": len(chapters),
"chapters": [{"number": ch["number"], "title": ch["title"]} for ch in chapters],
}
write_json(intro, os.path.join(output_dir, "intro.json"))
# Write per-chapter files
chapters_dir = os.path.join(output_dir, "chapters")
for ch in chapters:
chapter_data = {
"book": BOOK_CODE,
"chapter": ch["number"],
"title": ch["title"],
"type": "chapter",
"paragraphs": ch["paragraphs"],
}
filename = f"{ch['number']:02d}.json"
write_json(chapter_data, os.path.join(chapters_dir, filename))
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "others-before-1977.html")
output_dir = os.path.join(
base_dir, "lib", "jsons", "other-documents", "analysis-of-bhagavad-gita"
)
if not os.path.exists(html_path):
print(f"❌ HTML file not found: {html_path}")
sys.exit(1)
# Clean up old single-file JSON if it exists
old_json = os.path.join(
base_dir, "lib", "jsons", "other-documents", "analysis-of-bhagavad-gita.json"
)
if os.path.exists(old_json):
os.remove(old_json)
print(f" 🗑️ Removed old single-file JSON: {old_json}")
print(f"📂 Reading {html_path}...")
with open(html_path, "r", encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
print("\n📝 Extracting 'An Analysis of Bhagwat Geeta'...\n")
chapters = extract_chapters(soup)
quality_check(chapters)
write_output(chapters, output_dir)
print(f"\n🎉 Done! Output directory: {output_dir}")
if __name__ == "__main__":
main()
+533
View File
@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""Extract Prayers of King Kulaśekhara (1958) from others-before-1977.html.
The book has 6 chapters (one per verse/prayer):
1. Prayer 1 — "Śrī Vallabha" (prayer to Mukunda)
2. Prayer 2 — "Jayatu Jayatu" (glories to Devakī-nandana)
3. Prayer 3 — "Mukunda Mūrdhnā" (prayer for remembrance)
4. Prayer 4 — "Nāhaṁ Vande" (neither heaven nor hell)
5. Prayer 5 — "Nāsthā Dharme" (unflinching devotion)
6. Prayer 6 — "Divi Vā Bhuvi" (remembrance at death)
Output:
lib/jsons/books/pk/intro.json — Book metadata
lib/jsons/books/pk/chapters/*.json — Chapters (01–06)
Usage:
python3 scripts/pk_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, preserve formatting tags, strip spans."""
inner = "".join(str(child) for child in tag.children)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# Clean up Apple-tab-span leftovers (tab characters)
inner = inner.replace("\t", " ")
# Normalize whitespace
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def decode_text(tag: Tag) -> str:
"""Get plain text from a tag."""
return tag.get_text().strip()
def get_css_class(tag: Tag) -> str:
"""Get the first CSS class of a tag, or empty string."""
classes = tag.get("class", [])
return classes[0] if classes else ""
def is_empty(tag: Tag) -> bool:
"""Check if a tag is empty or whitespace-only."""
text = tag.get_text().strip()
return not text or text == "\xa0"
# ---------------------------------------------------------------------------
# CSS class categorization for PK
# ---------------------------------------------------------------------------
# Document header
DOC_HEADER_CLASS = "p4" # "Prayers of King Kulaçekhara 1958 [Handwritten]"
TITLE_CLASS = "p115" # Bold title
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p17" # "Prayers of King Kulaçekhara 1950: N"
# Verse number markers — skip
VERSE_MARKER_CLASSES = frozenset(["p108", "p9"])
# Devanagari marker — skip
DEVANAGARI_MARKER_CLASS = "p36"
# Section header class (Wordings., Synonyms., Translation., Purport.)
SECTION_HEADER_CLASSES = frozenset(["p20", "p121"])
# Body paragraph classes
BODY_CLASSES = frozenset(["p6", "p34", "p38", "p71"])
# Page number class — skip
PAGE_NUMBER_CLASSES = frozenset(["p15", "p41", "p124"])
# Empty/break classes — skip
SKIP_CLASSES = frozenset(["p24", "p94", "p119", "p122", "p123", "p120"])
# Next work boundary
NEXT_WORK_BOUNDARY_CLASS = "p125"
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_pk_boundaries(all_elements: list) -> tuple:
"""Find the start and end element indices of the PK content.
Start: <p class="p4"> containing "Prayers of King Kula"
End: <p class="p125"> that starts the next work ("Shaktipujah")
"""
start_idx = None
end_idx = len(all_elements)
for i, el in enumerate(all_elements):
if el.name != "p":
continue
cls = get_css_class(el)
text = decode_text(el)
# Find start: p4 containing "Prayers of King"
if start_idx is None:
if cls == DOC_HEADER_CLASS and "Prayers of King" in text:
start_idx = i
continue
continue
# Find end: p125 that starts a different work
if cls == NEXT_WORK_BOUNDARY_CLASS:
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Prayers of King Kulaçekhara' in others-before-1977.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_chapter_bookmarks(all_elements: list, start_idx: int, end_idx: int) -> list:
"""Find all PK chapter bookmark tags (p17) in the PK range.
Returns list of (element_index, verse_number).
"""
bookmarks = []
for i in range(start_idx, end_idx):
el = all_elements[i]
if el.name != "p":
continue
cls = get_css_class(el)
if cls == CHAPTER_BOOKMARK_CLASS:
text = decode_text(el).strip()
if "Prayers of King" in text or "Kulaçekhara" in text.replace("ç", "ç"):
# Extract the verse number from the end
match = re.search(r":\s*(\d+)\s*$", text)
if match:
verse_num = int(match.group(1))
bookmarks.append((i, verse_num))
return bookmarks
# ---------------------------------------------------------------------------
# Table synonym extraction
# ---------------------------------------------------------------------------
def extract_synonyms_from_table(table: Tag) -> str:
"""Extract synonyms from a <table class="t1"> and convert to text.
Each cell contains a <p> with "word=meaning" format.
Convert to "word—meaning; word—meaning; ..." format.
"""
entries = []
for row in table.find_all("tr"):
for cell in row.find_all("td"):
# Get all <p> tags inside the cell
for p in cell.find_all("p"):
text = decode_text(p).strip()
if not text or text == "\xa0":
continue
# Must contain '=' to be a synonym entry
if "=" not in text:
continue
# Replace = with em-dash
text = text.replace("=", "—", 1)
# Clean trailing whitespace/period if needed
text = text.strip()
entries.append(text)
if not entries:
return ""
return "; ".join(entries)
# ---------------------------------------------------------------------------
# Paragraph extraction for a chapter
# ---------------------------------------------------------------------------
def extract_chapter_paragraphs(
all_elements: list,
start_idx: int,
end_idx: int,
) -> list:
"""Extract paragraphs for a chapter (verse).
Handles section headers (Wordings., Synonyms., Translation., Purport.),
body paragraphs, and synonym tables.
Returns a list of HTML paragraph strings.
"""
result = []
# Build a set of <p> tags that are inside tables, so we don't double-process
table_p_tags = set()
for i in range(start_idx, end_idx):
el = all_elements[i]
if el.name == "table":
for p in el.find_all("p"):
table_p_tags.add(id(p))
for i in range(start_idx, end_idx):
el = all_elements[i]
# Handle tables (synonyms)
if el.name == "table":
table_cls = get_css_class(el)
if table_cls == "t1":
synonyms_text = extract_synonyms_from_table(el)
if synonyms_text:
result.append(synonyms_text)
continue
# Only process <p> tags from here
if el.name != "p":
continue
# Skip <p> tags that are inside tables (already processed)
if id(el) in table_p_tags:
continue
cls = get_css_class(el)
# Skip empty paragraphs
if is_empty(el):
continue
# Skip page numbers
if cls in PAGE_NUMBER_CLASSES:
continue
# Skip verse markers
if cls in VERSE_MARKER_CLASSES:
text = decode_text(el)
# p9 can also be "(Devanägaré)" or prayer number markers — skip all
continue
# Skip devanagari markers
if cls == DEVANAGARI_MARKER_CLASS:
continue
# Skip chapter bookmarks
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip document header and title (only at very start)
if cls == DOC_HEADER_CLASS:
continue
if cls == TITLE_CLASS:
continue
# Skip empty/break classes
if cls in SKIP_CLASSES:
continue
# Section headers: Wordings., Synonyms., Translation., Purport.
if cls in SECTION_HEADER_CLASSES:
text = decode_text(el).strip()
# Normalize section header names
text_lower = text.lower().rstrip(".")
if "wording" in text_lower:
result.append("<b>Wordings</b>")
elif (
"synonym" in text_lower
or "synomym" in text_lower
or "synonysm" in text_lower
):
result.append("<b>Synonyms</b>")
elif "translation" in text_lower:
result.append("<b>Translation</b>")
elif "purport" in text_lower:
result.append("<b>Purport</b>")
else:
# Unknown header — include as bold
html = decode_html_content(el)
if html:
result.append(f"<b>{html}</b>")
continue
# Body paragraphs
if cls in BODY_CLASSES:
html = decode_html_content(el)
if html:
result.append(html)
continue
# Fallback: try to extract content from unknown classes
html = decode_html_content(el)
if html:
result.append(html)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_pk(soup: BeautifulSoup) -> dict:
"""Extract the entire Prayers of King Kulaśekhara from the parsed HTML.
Returns a dict with:
"chapters": list of chapter dicts
"chapter_listing": list of {number, title} for intro.json
"""
# We need both <p> and <table> elements in document order
all_elements = list(soup.find_all(["p", "table"]))
# Step 1: Find PK boundaries
pk_start, pk_end = find_pk_boundaries(all_elements)
print(f" PK content: elements {pk_start} to {pk_end}")
print(f" Total elements in range: {pk_end - pk_start}")
# Step 2: Find all chapter bookmarks (p17)
bookmarks = find_chapter_bookmarks(all_elements, pk_start, pk_end)
print(f" Found {len(bookmarks)} PK chapter bookmarks:")
for pos, verse_num in bookmarks:
print(f" Element {pos}: Verse {verse_num}")
# If we didn't find 6 bookmarks, also look for the first verse that starts
# before any p17 bookmark (verse 1 may start with p108 [1])
# Actually, verse 1's bookmark IS in the HTML. Let's verify.
if len(bookmarks) < 6:
print(f" WARNING: Expected 6 bookmarks, found {len(bookmarks)}")
# Step 3: Build chapter ranges
# Each bookmark marks the START of a verse section.
# Content for verse N runs from bookmark N to bookmark N+1 (or pk_end).
chapters = []
for idx, (bk_pos, verse_num) in enumerate(bookmarks):
# Content starts after the bookmark
content_start = bk_pos + 1
# Content ends at next bookmark or pk_end
if idx + 1 < len(bookmarks):
content_end = bookmarks[idx + 1][0]
else:
content_end = pk_end
paragraphs = extract_chapter_paragraphs(
all_elements, content_start, content_end
)
ch = {
"book": "pk",
"chapter": verse_num,
"title": f"Prayer {verse_num}",
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 4: Build chapter listing for intro.json
chapter_listing = [
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
]
return {
"chapters": chapters,
"chapter_listing": chapter_listing,
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_json(data, filepath):
"""Write data as formatted JSON to filepath, creating directories as needed."""
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", "others-before-1977.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "pk")
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 Prayers of King Kulaśekhara (1958)...\n")
result = extract_pk(soup)
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "pk",
"title": "Prayers of King Kulaśekhara (1958)",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1958,
"description": "Early handwritten translations and purports of the Mukunda-mālā-stotra prayers (verses 1–6).",
"totalChapters": 6,
"chapters": chapter_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write chapters ---
print(f"\n--- Chapters ---")
for ch in chapters:
ch_num_str = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}"
)
# --- Summary ---
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Chapters: {len(chapters)}")
for ch in chapters:
print(
f" Ch {ch['chapter']}: {len(ch['paragraphs'])} paragraphs — {ch['title']}"
)
print(f" Total paragraphs: {total_chapter_paras}")
if chapters:
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
for ch_idx, ch in enumerate(chapters):
print(f"\n--- Sample: Chapter {ch['chapter']} ({ch['title']}) ---")
for i, p in enumerate(ch["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}")
if len(ch["paragraphs"]) > 5:
print(f" ... ({len(ch['paragraphs'])} total paragraphs)")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 6 chapters present
expected_chapters = set(range(1, 7))
actual_chapters = set(ch["chapter"] for ch in chapters)
missing = expected_chapters - actual_chapters
extra = actual_chapters - expected_chapters
if missing:
print(f" ⚠ Missing chapters: {sorted(missing)}")
if extra:
print(f" ⚠ Extra chapters: {sorted(extra)}")
if not missing and not extra:
print(f" ✅ All 6 chapters present")
# Check empty chapters
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
if empty_chapters:
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
else:
print(f" ✅ No empty chapters")
# Check each chapter has expected sections
for ch in chapters:
ch_text = " ".join(ch["paragraphs"])
has_wordings = "<b>Wordings</b>" in ch_text
has_synonyms = "<b>Synonyms</b>" in ch_text
has_translation = "<b>Translation</b>" in ch_text
has_purport = "<b>Purport</b>" in ch_text
sections = []
if has_wordings:
sections.append("W")
if has_synonyms:
sections.append("S")
if has_translation:
sections.append("T")
if has_purport:
sections.append("P")
status = "✅" if len(sections) == 4 else "⚠"
print(f" {status} Ch {ch['chapter']}: sections [{', '.join(sections)}]")
# Check synonym entries use em-dash format
synonym_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "—" in p and "<b>" not in p:
synonym_count += 1
break
print(f" ℹ Chapters with em-dash synonyms: {synonym_count}/6")
# Verify no Shaktipujah content leaked in
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"])
if "Shaktipujah" in all_text or "Baidya Nath" in all_text:
print(f" ⚠ Content from Shaktipujah may have leaked into PK!")
else:
print(f" ✅ No content leak from next work detected")
# Check last chapter ends cleanly
if chapters:
last_ch = chapters[-1]
last_para = last_ch["paragraphs"][-1] if last_ch["paragraphs"] else ""
last_preview = re.sub(r"<[^>]+>", "", last_para)[:80]
print(f' ℹ Last paragraph of Ch {last_ch["chapter"]}: "{last_preview}..."')
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
/**
* seed-bk.ts — Push Bhagavāner Kathā JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-cbhc.ts) to avoid
* Prisma replica-set write requirements.
*
* BK is a simple prose book with no volume structure and no intro segments.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "bk", data: <intro.json> }
* - prose_chapters → 2 chapters
*
* Usage:
* npx tsx scripts/seed-bk.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const BK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "bk");
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
/**
* Insert documents one at a time with a small delay every N docs.
* Uses insertOne to avoid BSON wire-protocol corruption.
*/
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]);
if ((i + 1) % 100 === 0) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
// ---------------------------------------------------------------------------
// Types for the JSON files
// ---------------------------------------------------------------------------
type BkIntroJson = {
book: string;
title: string;
author: string;
year: number | null;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type BkChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedBk(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(BK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ BK intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<BkIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "bk" });
await metaCol.insertOne({ book: "bk", data: intro });
console.log(" 📖 BK book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "bk" });
const docs: Record<string, unknown>[] = [];
// Chapters (01.json through 02.json)
const chaptersDir = path.join(BK_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of chapterFiles) {
const ch = readJson<BkChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "bk",
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📖 Chapters: ${chapterFiles.length}`);
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "bk" });
console.log(` ✅ BK: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Bhagavāner Kathā...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
await seedBk(db);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+169
View File
@@ -0,0 +1,169 @@
/**
* seed-cbhc.ts — Push Śrī Caitanya-bhāgavata Commentary JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-mog.ts) to avoid
* Prisma replica-set write requirements.
*
* CBHC is a simple prose book with no volume structure and no intro segments.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "cbhc", data: <intro.json> }
* - prose_chapters → 4 chapters
*
* Usage:
* npx tsx scripts/seed-cbhc.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const CBHC_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "cbhc");
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
/**
* Insert documents one at a time with a small delay every N docs.
* Uses insertOne to avoid BSON wire-protocol corruption.
*/
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]);
if ((i + 1) % 100 === 0) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
// ---------------------------------------------------------------------------
// Types for the JSON files
// ---------------------------------------------------------------------------
type CbhcIntroJson = {
book: string;
title: string;
author: string;
year: number | null;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type CbhcChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedCbhc(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(CBHC_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ CBHC intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<CbhcIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "cbhc" });
await metaCol.insertOne({ book: "cbhc", data: intro });
console.log(" 📖 CBHC book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "cbhc" });
const docs: Record<string, unknown>[] = [];
// Chapters (01.json through 04.json)
const chaptersDir = path.join(CBHC_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of chapterFiles) {
const ch = readJson<CbhcChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "cbhc",
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📖 Chapters: ${chapterFiles.length}`);
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "cbhc" });
console.log(` ✅ CBHC: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Śrī Caitanya-bhāgavata Commentary...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
await seedCbhc(db);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+163
View File
@@ -0,0 +1,163 @@
/**
* seed-cc-essays.ts — Push all five "Essays on Śrī Caitanya-caritāmṛta" into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-te.ts) to avoid
* Prisma replica-set write requirements.
*
* Collections written:
* - essays → 5 essay docs (upsert by slug)
*
* JSON sources:
* - lib/jsons/essays/the-spiritual-masters.json
* - lib/jsons/essays/the-absolute-truth.json
* - lib/jsons/essays/causes-of-lord-caitanyas-incarnation.json
* - lib/jsons/essays/confidential-reasons-for-lord-caitanyas-appearance.json
* - lib/jsons/essays/glories-of-lord-nityananda.json
*
* Usage:
* npx tsx scripts/seed-cc-essays.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const ESSAYS_DIR = path.join(BASE_DIR, "lib", "jsons", "essays");
const ESSAY_FILES = [
"the-spiritual-masters.json",
"the-absolute-truth.json",
"causes-of-lord-caitanyas-incarnation.json",
"confidential-reasons-for-lord-caitanyas-appearance.json",
"glories-of-lord-nityananda.json",
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type EssayJson = {
slug: string;
title: string;
author: string;
year: number;
group: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedCcEssays(db: ReturnType<MongoClient["db"]>) {
const essaysCol = db.collection("essays");
let inserted = 0;
let updated = 0;
let unchanged = 0;
for (const filename of ESSAY_FILES) {
const jsonPath = path.join(ESSAYS_DIR, filename);
if (!fs.existsSync(jsonPath)) {
console.error(` ❌ Missing: ${jsonPath}`);
console.error(" Run `python3 scripts/cc_essays_extract.py` first.");
process.exit(1);
}
const essay = readJson<EssayJson>(jsonPath);
const result = await essaysCol.updateOne(
{ slug: essay.slug },
{
$set: {
slug: essay.slug,
title: essay.title,
author: essay.author,
year: essay.year,
group: essay.group,
order: essay.order,
paragraphs: essay.paragraphs,
},
},
{ upsert: true },
);
if (result.upsertedCount > 0) {
console.log(
` 📝 Inserted "${essay.title}" (${essay.paragraphs.length} paragraphs)`,
);
inserted++;
} else if (result.modifiedCount > 0) {
console.log(
` 📝 Updated "${essay.title}" (${essay.paragraphs.length} paragraphs)`,
);
updated++;
} else {
console.log(` 📝 "${essay.title}" already up to date`);
unchanged++;
}
}
// Verify
const totalDocs = await essaysCol.countDocuments({
group: "cc-essays",
});
console.log(
`\n ✅ Verified: ${totalDocs} document(s) in essays collection with group "cc-essays"`,
);
// Summary
console.log(
`\n Summary: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged`,
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding CC Essays (5 essays)...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
await seedCcEssays(db);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+171
View File
@@ -0,0 +1,171 @@
/**
* seed-journals.ts — Push Journals into MongoDB.
*
* Collections written:
* - journal_meta → one doc per journal
* - journal_chapters → N chapter docs per journal
*
* Usage:
* npx tsx scripts/seed-journals.ts
*/
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 BASE_DIR = path.resolve(__dirname, "..");
const JOURNALS_DIR = path.join(BASE_DIR, "lib", "jsons", "journals");
type IntroJson = {
book: string;
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
chapters: unknown[];
};
type ChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
function sanitizeDoc(obj: unknown): unknown {
if (typeof obj === "string") {
let s = obj.normalize("NFC");
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
s = s.replace(/[\uD800-\uDFFF]/g, "");
s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, "");
s = Buffer.from(s, "utf-8").toString("utf-8");
return s;
}
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>))
result[key] = sanitizeDoc(value);
return result;
}
return obj;
}
async function seedOneJournal(
db: ReturnType<MongoClient["db"]>,
docDir: string,
): Promise<{ bookCode: string; chapters: number }> {
const introPath = path.join(docDir, "intro.json");
if (!fs.existsSync(introPath)) throw new Error(`No intro.json in ${docDir}`);
const intro = sanitizeDoc(readJson<IntroJson>(introPath)) as IntroJson;
const bookCode = intro.book;
const metaCol = db.collection("journal_meta");
await metaCol.deleteMany({ book: bookCode });
await metaCol.insertOne({ book: bookCode, data: intro });
console.log(` 📖 Inserted journal_meta for "${intro.title}" (${bookCode})`);
const chaptersCol = db.collection("journal_chapters");
await chaptersCol.deleteMany({ book: bookCode });
const chaptersDir = path.join(docDir, "chapters");
let inserted = 0;
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = sanitizeDoc(
readJson<ChapterJson>(path.join(chaptersDir, file)),
) as ChapterJson;
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
const slug = ch.slug ?? String(chNum);
await chaptersCol.insertOne({
book: bookCode,
chapter: chNum,
title: ch.title,
type: ch.type || "chapter",
slug,
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
inserted++;
}
console.log(` 📄 Chapters: ${inserted}`);
}
const total = await chaptersCol.countDocuments({ book: bookCode });
console.log(` ✅ ${bookCode}: ${total} chapter entries`);
return { bookCode, chapters: inserted };
}
async function main() {
console.log("🌱 Seeding Journals...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
if (!fs.existsSync(JOURNALS_DIR)) {
console.error(`❌ ${JOURNALS_DIR} not found`);
process.exit(1);
}
const subdirs = fs
.readdirSync(JOURNALS_DIR)
.filter((e) => {
const p = path.join(JOURNALS_DIR, e);
return (
fs.statSync(p).isDirectory() &&
fs.existsSync(path.join(p, "intro.json"))
);
})
.sort();
if (subdirs.length === 0) {
console.error("❌ No journal directories found");
process.exit(1);
}
console.log(`📂 Found ${subdirs.length} journal(s)\n`);
let totalChapters = 0;
const bookCodes: string[] = [];
for (const subdir of subdirs) {
console.log(`\n📝 Processing: ${subdir}`);
const result = await seedOneJournal(db, path.join(JOURNALS_DIR, subdir));
bookCodes.push(result.bookCode);
totalChapters += result.chapters;
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log("\n" + "─".repeat(50));
console.log(
`📊 Summary: ${subdirs.length} journals, ${totalChapters} chapters`,
);
console.log(` Book codes: ${bookCodes.join(", ")}`);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-mog.ts — Push Message of Godhead JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-mg.ts) to avoid
* Prisma replica-set write requirements.
*
* MOG is a prose book with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "mog", data: <intro.json> }
* - prose_chapters → 1 intro segment + 3 chapters = 4 docs
*
* Usage:
* npx tsx scripts/seed-mog.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const MOG_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "mog");
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
/**
* Insert documents one at a time with a small delay every N docs.
* Uses insertOne to avoid BSON wire-protocol corruption.
*/
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]);
if ((i + 1) % 100 === 0) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
// ---------------------------------------------------------------------------
// Types for the JSON files
// ---------------------------------------------------------------------------
type MogIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type MogIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type MogChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedMog(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(MOG_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ MOG intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<MogIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "mog" });
await metaCol.insertOne({ book: "mog", data: intro });
console.log(" 📖 MOG book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "mog" });
const docs: Record<string, unknown>[] = [];
// Intro segments (front-pages)
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(MOG_DIR, "intro");
for (const file of introSegmentFiles) {
const filePath = path.join(introDir, file);
if (!fs.existsSync(filePath)) {
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
continue;
}
const seg = readJson<MogIntroSegmentJson>(filePath);
docs.push({
book: "mog",
volume: NO_VOLUME,
chapter: null,
title: seg.title,
type: "intro-segment",
slug: `intro-${seg.slug}`,
order: seg.order,
paragraphs: seg.paragraphs,
});
}
console.log(` 📄 Intro segments: ${docs.length}`);
// Chapters (01.json through 03.json)
const chaptersDir = path.join(MOG_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of chapterFiles) {
const ch = readJson<MogChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "mog",
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📖 Chapters: ${chapterFiles.length}`);
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "mog" });
console.log(` ✅ MOG: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Message of Godhead...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
await seedMog(db);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+274
View File
@@ -0,0 +1,274 @@
/**
* seed-other-documents.ts — Push "Other Documents" into MongoDB.
*
* Uses dedicated collections for other documents (not shared with books):
* - other_document_meta → one doc per document { book: "<slug>", data: <intro.json> }
* - other_document_chapters → N chapter docs per document
*
* Each "other document" is a subdirectory under lib/jsons/other-documents/
* with an intro.json and chapters/*.json.
*
* Book codes have NO prefix (e.g. "analysis-bg", "real-peace-formula").
*
* Usage:
* npx tsx scripts/seed-other-documents.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const OTHER_DOCS_DIR = path.join(BASE_DIR, "lib", "jsons", "other-documents");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IntroJson = {
book: string;
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type ChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
/**
* Recursively sanitize all strings in a document to prevent BSON encoding errors.
*/
function sanitizeDoc(obj: unknown): unknown {
if (typeof obj === "string") {
let s = obj.normalize("NFC");
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
s = s.replace(/[\uD800-\uDFFF]/g, "");
s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, "");
s = Buffer.from(s, "utf-8").toString("utf-8");
return s;
}
if (Array.isArray(obj)) {
return obj.map(sanitizeDoc);
}
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = sanitizeDoc(value);
}
return result;
}
return obj;
}
// ---------------------------------------------------------------------------
// Seed one document
// ---------------------------------------------------------------------------
async function seedOneDocument(
db: ReturnType<MongoClient["db"]>,
docDir: string,
): Promise<{ bookCode: string; chapters: number }> {
const introPath = path.join(docDir, "intro.json");
if (!fs.existsSync(introPath)) {
throw new Error(`No intro.json found in ${docDir}`);
}
const intro = sanitizeDoc(readJson<IntroJson>(introPath)) as IntroJson;
const bookCode = intro.book;
// ---- Document meta ----
const metaCol = db.collection("other_document_meta");
await metaCol.deleteMany({ book: bookCode });
await metaCol.insertOne({ book: bookCode, data: intro });
console.log(
` 📖 Inserted other_document_meta for "${intro.title}" (${bookCode})`,
);
// ---- Document chapters ----
const chaptersCol = db.collection("other_document_chapters");
await chaptersCol.deleteMany({ book: bookCode });
const docs: Record<string, unknown>[] = [];
const chaptersDir = path.join(docDir, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of chapterFiles) {
const ch = sanitizeDoc(
readJson<ChapterJson>(path.join(chaptersDir, file)),
) as ChapterJson;
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: bookCode,
chapter: chNum,
title: ch.title,
type: ch.type || "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📄 Chapters: ${chapterFiles.length}`);
}
// Insert all chapter docs
if (docs.length > 0) {
console.log(
` 💾 Inserting ${docs.length} other_document_chapters docs...`,
);
for (const doc of docs) {
await chaptersCol.insertOne(doc);
}
}
const totalDocs = await chaptersCol.countDocuments({ book: bookCode });
console.log(` ✅ ${bookCode}: ${totalDocs} chapter entries in MongoDB`);
return { bookCode, chapters: docs.length };
}
// ---------------------------------------------------------------------------
// Clean up old data from shared collections
// ---------------------------------------------------------------------------
async function cleanupOldData(db: ReturnType<MongoClient["db"]>) {
// Remove od- prefixed entries from book_meta (old location)
const bookMetaCol = db.collection("book_meta");
const deletedMeta = await bookMetaCol.deleteMany({
book: { $regex: /^od-/ },
});
if (deletedMeta.deletedCount > 0) {
console.log(
` 🗑️ Deleted ${deletedMeta.deletedCount} od-* doc(s) from book_meta`,
);
}
// Remove od- prefixed entries from prose_chapters (old location)
const proseCol = db.collection("prose_chapters");
const deletedProse = await proseCol.deleteMany({
book: { $regex: /^od-/ },
});
if (deletedProse.deletedCount > 0) {
console.log(
` 🗑️ Deleted ${deletedProse.deletedCount} od-* doc(s) from prose_chapters`,
);
}
// Clean up legacy other_documents collection
const collections = await db
.listCollections({ name: "other_documents" })
.toArray();
if (collections.length > 0) {
const col = db.collection("other_documents");
const count = await col.countDocuments();
if (count > 0) {
await col.deleteMany({});
console.log(
` 🗑️ Deleted ${count} doc(s) from legacy other_documents collection`,
);
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Other Documents...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
// Discover all subdirectories
if (!fs.existsSync(OTHER_DOCS_DIR)) {
console.error(`❌ Directory not found: ${OTHER_DOCS_DIR}`);
process.exit(1);
}
const subdirs = fs
.readdirSync(OTHER_DOCS_DIR)
.filter((entry) => {
const fullPath = path.join(OTHER_DOCS_DIR, entry);
return (
fs.statSync(fullPath).isDirectory() &&
fs.existsSync(path.join(fullPath, "intro.json"))
);
})
.sort();
if (subdirs.length === 0) {
console.error(`❌ No document directories found in ${OTHER_DOCS_DIR}`);
process.exit(1);
}
console.log(`📂 Found ${subdirs.length} document(s)\n`);
// Seed each document
let totalChapters = 0;
const bookCodes: string[] = [];
for (const subdir of subdirs) {
console.log(`\n📝 Processing: ${subdir}`);
const docDir = path.join(OTHER_DOCS_DIR, subdir);
const result = await seedOneDocument(db, docDir);
bookCodes.push(result.bookCode);
totalChapters += result.chapters;
}
// Clean up old data from shared collections
console.log("\n🧹 Cleaning up old data from shared collections...");
await cleanupOldData(db);
// Summary
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log("\n" + "─".repeat(50));
console.log(`📊 Summary:`);
console.log(` Documents: ${subdirs.length}`);
console.log(` Book codes: ${bookCodes.join(", ")}`);
console.log(` Total chapters: ${totalChapters}`);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+169
View File
@@ -0,0 +1,169 @@
/**
* seed-pk.ts — Push Prayers of King Kulaśekhara JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-mog.ts) to avoid
* Prisma replica-set write requirements.
*
* PK is a prose book with no volume structure and no intro segments.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "pk", data: <intro.json> }
* - prose_chapters → 6 chapters (one per verse/prayer)
*
* Usage:
* npx tsx scripts/seed-pk.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const PK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "pk");
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
/**
* Insert documents one at a time with a small delay every N docs.
* Uses insertOne to avoid BSON wire-protocol corruption.
*/
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]);
if ((i + 1) % 100 === 0) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
// ---------------------------------------------------------------------------
// Types for the JSON files
// ---------------------------------------------------------------------------
type PkIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type PkChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedPk(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(PK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ PK intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<PkIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "pk" });
await metaCol.insertOne({ book: "pk", data: intro });
console.log(" 📖 PK book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "pk" });
const docs: Record<string, unknown>[] = [];
// Chapters (01.json through 06.json)
const chaptersDir = path.join(PK_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of chapterFiles) {
const ch = readJson<PkChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "pk",
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📖 Chapters: ${chapterFiles.length}`);
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "pk" });
console.log(` ✅ PK: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Prayers of King Kulaśekhara (1958)...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
await seedPk(db);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+179
View File
@@ -0,0 +1,179 @@
/**
* seed-pre-1977-essays.ts — Push thirteen pre-1977 essays into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-cc-essays.ts) to avoid
* Prisma replica-set write requirements.
*
* Collections written:
* - essays → 9 essay docs (upsert by slug)
*
* JSON sources:
* - lib/jsons/essays/man-is-the-architect-of-his-own-fortune.json
* - lib/jsons/essays/what-is-the-matter-with-the-world.json
* - lib/jsons/essays/religion-is-godly-life.json
* - lib/jsons/essays/truth-and-beauty.json
* - lib/jsons/essays/misuse-of-public-fund-in-the-name-of-religion.json
* - lib/jsons/essays/perfection-at-home.json
* - lib/jsons/essays/how-should-one-cultivate-human-spirit.json
* - lib/jsons/essays/viswa-dharma.json
* - lib/jsons/essays/glorification-of-srila-bhaktisiddhanta-sarasvati-thakura.json
* - lib/jsons/essays/who-is-crazy-btg66.json
* - lib/jsons/essays/manifestation-of-dharma-in-human-being.json
* - lib/jsons/essays/message-of-krishna-consciousness.json
* - lib/jsons/essays/a-wrong-civilization.json
*
* Usage:
* npx tsx scripts/seed-pre-1977-essays.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const ESSAYS_DIR = path.join(BASE_DIR, "lib", "jsons", "essays");
const ESSAY_FILES = [
"man-is-the-architect-of-his-own-fortune.json",
"what-is-the-matter-with-the-world.json",
"religion-is-godly-life.json",
"truth-and-beauty.json",
"misuse-of-public-fund-in-the-name-of-religion.json",
"perfection-at-home.json",
"how-should-one-cultivate-human-spirit.json",
"viswa-dharma.json",
"glorification-of-srila-bhaktisiddhanta-sarasvati-thakura.json",
"who-is-crazy-btg66.json",
"manifestation-of-dharma-in-human-being.json",
"message-of-krishna-consciousness.json",
"a-wrong-civilization.json",
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type EssayJson = {
slug: string;
title: string;
author: string;
year: number | null;
group: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedPre1977Essays(db: ReturnType<MongoClient["db"]>) {
const essaysCol = db.collection("essays");
let inserted = 0;
let updated = 0;
let unchanged = 0;
for (const filename of ESSAY_FILES) {
const jsonPath = path.join(ESSAYS_DIR, filename);
if (!fs.existsSync(jsonPath)) {
console.error(` ❌ Missing: ${jsonPath}`);
console.error(" Ensure the JSON file exists in lib/jsons/essays/.");
process.exit(1);
}
const essay = readJson<EssayJson>(jsonPath);
const result = await essaysCol.updateOne(
{ slug: essay.slug },
{
$set: {
slug: essay.slug,
title: essay.title,
author: essay.author,
year: essay.year,
group: essay.group,
order: essay.order,
paragraphs: essay.paragraphs,
},
},
{ upsert: true },
);
if (result.upsertedCount > 0) {
console.log(
` 📝 Inserted "${essay.title}" (${essay.paragraphs.length} paragraphs)`,
);
inserted++;
} else if (result.modifiedCount > 0) {
console.log(
` 📝 Updated "${essay.title}" (${essay.paragraphs.length} paragraphs)`,
);
updated++;
} else {
console.log(` 📝 "${essay.title}" already up to date`);
unchanged++;
}
}
// Verify
const totalDocs = await essaysCol.countDocuments({
group: "pre-1977-essays",
});
console.log(
`\n ✅ Verified: ${totalDocs} document(s) in essays collection with group "pre-1977-essays"`,
);
// Summary
console.log(
`\n Summary: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged`,
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Pre-1977 Essays (13 essays)...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
await seedPre1977Essays(db);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+139
View File
@@ -0,0 +1,139 @@
/**
* seed-va.ts — Push Vaiśiṣṭyāṣṭakam (VA) into MongoDB.
*
* Collections written:
* - book_meta → 1 doc { book: "va", data: <intro.json> }
* - prose_chapters → 8 chapter docs
*
* JSON sources:
* - lib/jsons/books/va/intro.json
* - lib/jsons/books/va/chapters/01.json through 08.json
*
* Usage:
* npx tsx scripts/seed-va.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
// Load env
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 in .env");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const BOOK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "va");
const BOOK = "va";
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IntroJson = {
book: string;
title: string;
author: string;
year: number | null;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type ChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Vaiśiṣṭyāṣṭakam (VA)...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
// ---- Read intro ----
const introPath = path.join(BOOK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error(`❌ Missing: ${introPath}`);
process.exit(1);
}
const intro = readJson<IntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: BOOK });
await metaCol.insertOne({ book: BOOK, data: intro });
console.log(`📖 Inserted book_meta for "${intro.title}" (${BOOK})`);
// ---- Chapters ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: BOOK });
const chaptersDir = path.join(BOOK_DIR, "chapters");
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
let inserted = 0;
for (const file of chapterFiles) {
const ch = readJson<ChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter;
await proseCol.insertOne({
book: BOOK,
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: ch.type || "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
console.log(
` 📄 Chapter ${chNum}: "${ch.title}" (${ch.paragraphs.length} paragraphs)`,
);
inserted++;
}
// Verify
const totalDocs = await proseCol.countDocuments({ book: BOOK });
console.log(`\n✅ Verified: ${totalDocs} prose_chapters docs for ${BOOK}`);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n🎉 Done in ${elapsed}s (${inserted} chapters inserted)`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
File diff suppressed because it is too large Load Diff