feat: add ssr

This commit is contained in:
gosangam
2026-04-17 22:51:58 +05:30
parent d88ababd76
commit 0aab1d23ec
97 changed files with 29891 additions and 68 deletions
+428
View File
@@ -0,0 +1,428 @@
# Prabhupāda Says — Project Context
## What this project is
A Next.js app that converts legacy RTF files containing Vedic scriptures (Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, Kṛṣṇa Book, letters, magazines, etc.) into structured, browsable content with proper Unicode Sanskrit.
The original RTF files use **custom font encodings** (Indevr, ScaGoudy, Inbenr/Inbeni, RM Devanagari, Balaram, etc.) where ASCII characters map to Devanagari/Bengali glyphs and IAST diacritical marks.
---
## Tech Stack
- **Next.js 16** with Turbopack, React 19, TypeScript
- **Tailwind CSS v4** with `@tailwindcss/postcss`
- **shadcn/ui** (base-nova style, neutral base color, lucide icons) — components: Button, Card, Badge, Tooltip, Input
- **next-themes** for dark/light mode
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
---
## Project Structure
```
spsays-training/
├── app/
│ ├── page.tsx → "Prabhupāda Says" landing page (Books, Glossary, RTFs)
│ ├── layout.tsx → ThemeProvider + TooltipProvider wrapper
│ ├── globals.css → shadcn/Tailwind v4 CSS with dark mode
│ ├── books/
│ │ ├── page.tsx → Books index (BG, SB, CC, SB1962, KB)
│ │ ├── chapter-viewer.tsx → Shared client component for verse-based books
│ │ ├── extracted-verse-count.tsx → (i) tooltip for verse counts
│ │ ├── bg/ → Bhagavad-gītā pages
│ │ │ ├── page.tsx → Chapter list
│ │ │ └── [chapter]/
│ │ │ ├── page.tsx → Redirects to first verse
│ │ │ └── [verse]/page.tsx → Single verse viewer
│ │ ├── sb/ → Śrīmad-Bhāgavatam pages
│ │ │ ├── page.tsx → Canto list
│ │ │ └── [canto]/
│ │ │ ├── page.tsx → Chapter list
│ │ │ └── [chapter]/
│ │ │ ├── page.tsx → Redirects to first verse
│ │ │ └── [verse]/page.tsx → Single verse viewer
│ │ ├── cc/ → Caitanya-caritāmṛta pages (same pattern, [lila]/[chapter]/[verse])
│ │ ├── sb1962/ → 1962 SB First Canto (same pattern as BG, [chapter]/[verse])
│ │ └── kb/ → Kṛṣṇa Book (prose, no verses)
│ │ ├── page.tsx → Volume list
│ │ └── [volume]/
│ │ ├── page.tsx → Chapter list + intro segments
│ │ └── [slug]/page.tsx → Reading page with sidebar
│ ├── glossary/
│ │ ├── page.tsx → Server component wrapper
│ │ └── glossary-client.tsx → Searchable client component
│ ├── rtfs/
│ │ ├── page.tsx → RTF file listing (gated by ALLOW_RTF env)
│ │ └── [slug]/page.tsx → RTF iframe viewer (gated by ALLOW_RTF env)
│ └── api/
│ ├── books/
│ │ ├── route.ts → GET all book metas
│ │ └── [book]/
│ │ ├── route.ts → GET single book meta
│ │ └── verses/route.ts → GET verses (?chapter=&verse=&division=&indexOnly=&page=&limit=)
│ └── glossary/route.ts → GET all glossary entries
├── components/
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input)
│ ├── page-header.tsx → Shared header with ThemeToggle
│ ├── theme-provider.tsx → next-themes wrapper
│ └── theme-toggle.tsx → Sun/moon toggle
├── lib/
│ ├── db.ts → Prisma client singleton
│ ├── api.ts → Data access layer (getAllBooks, getBookMeta, getChapterVerses, etc.)
│ ├── utils.ts → cn() helper
│ └── jsons/books/ → Extracted JSON data
│ ├── bg/ → BG chapters/verses/*.json + intro.json
│ ├── sb/ → SB cantos/chapters/verses/*.json + intro.json
│ ├── cc/ → CC lilas/chapters/verses/*.json + intro.json
│ ├── sb-1962/ → 1962 SB chapters/verses/*.json + intro.json
│ ├── kb/ → Kṛṣṇa Book
│ │ ├── intro.json → Book metadata + chapter listing (89 chapters)
│ │ ├── vol-1/intro/ → 5 intro segments (flap, dedication, George Harrison, preface, intro)
│ │ ├── vol-1/chapters/ → 54 chapter JSONs
│ │ └── vol-2/chapters/ → 35 chapter JSONs
│ └── glossary/index.json → 2,749 glossary entries
├── prisma/
│ └── schema.prisma → Verse, BookMeta, ProseChapter, GlossaryEntry models
├── scripts/
│ ├── bg_extract.py → BG extraction from bg.html
│ ├── sb_extract.py → SB extraction from sb.html
│ ├── cc_extract.py → CC extraction from cc.html
│ ├── sb1962_extract.py → 1962 SB extraction from others-before-1977.html
│ ├── match_sb1962.py → Match 1962 SB transliterations with standard SB IAST
│ ├── glossary_extract.py → Glossary extraction from glossary.html
│ ├── kb_extract_intro.py → KB book-level intro + chapter listing
│ ├── kb_extract_vol1_intro.py → KB Vol 1 front matter segments
│ ├── kb_extract_vol1_chapters.py → KB Vol 1 chapters
│ ├── kb_extract_vol2_chapters.py → KB Vol 2 chapters
│ ├── fix-bengali-encoding.py → Fix Bengali font encoding in cc.html
│ ├── inject-fonts.py → Inject @font-face rules into converted HTML
│ ├── seed-db.ts → Main MongoDB seeder (all books + glossary)
│ └── seed-kb-vol2.ts → KB Vol 2 seeder (uses $push workaround)
└── public/
├── rtfs/ → Original RTF files (~430MB, 17 files)
├── html/ → HTML converted from RTF (textutil + @font-face injection)
└── fonts/ → Custom TTF fonts (Balaram, ScaGoudy, Indevr, Inbenr, etc.)
```
---
## Database Schema (Prisma + MongoDB)
```prisma
type Synonym {
word String
meanings String[]
}
model Verse {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "bg" | "sb" | "cc" | "sb1962"
division String? // null for BG/SB1962; canto for SB; lila for CC
chapter Int
verse Int
verseEnd Int?
reference String
bookTitle String
devanagari String
bengali String? // only CC has Bengali
transliteration String
synonyms Synonym[]
translation String
purport String[]
@@unique([book, division, chapter, verse])
@@index([book])
@@index([book, division])
@@index([book, division, chapter])
@@map("verses")
}
model BookMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique // "bg" | "sb" | "cc" | "sb1962" | "kb"
data Json
@@map("book_meta")
}
model ProseChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "kb"
volume Int
chapter Int? // null for intro segments
title String
type String // "chapter" | "intro-segment"
slug String
order Int
paragraphs String[]
@@unique([book, volume, slug])
@@index([book])
@@index([book, volume])
@@index([book, volume, type])
@@map("prose_chapters")
}
model GlossaryEntry {
id String @id @default(auto()) @map("_id") @db.ObjectId
term String
definition String
definitionHtml String
@@unique([term])
@@map("glossary")
}
```
---
## Books Extracted
| Book | Code | Type | Verses/Chapters | Division |
|------|------|------|-----------------|----------|
| Bhagavad-gītā As It Is | `bg` | verse | 653 verses, 18 chapters | — |
| Śrīmad-Bhāgavatam | `sb` | verse | 8,260 verses, 213 chapters | 10 cantos |
| Śrī Caitanya-caritāmṛta | `cc` | verse | 11,348 verses, 62 chapters | 3 līlās (adi/madhya/antya) |
| SB First Canto (1962 Edition) | `sb1962` | verse | 762 verses, 19 chapters | — |
| Kṛṣṇa Book | `kb` | prose | 89 chapters (54+35), 2 volumes | 2 volumes |
| Glossary | — | glossary | 2,749 entries | — |
---
## Font Encoding System
### ScaGoudy → IAST mapping (used in BG, SB, CC, glossary)
```
ä→ā Ä→Ā å→ṛ Å→Ṛ ñ→ṣ Ñ→Ṣ ö→ṭ Ö→Ṭ ë→ṇ Ë→Ṇ ò→ḍ Ò→Ḍ
ç→ś Ç→Ś ì→ṅ ï→ñ ü→ū Ü→Ū é→ī É→Ī à→ṁ ù→ḥ è→ṝ û→ṁl ÿ→ḷ
```
### Bengali fonts (Inbenr/Inbeni in CC)
Custom-encoded fonts where ASCII codepoints render as Bengali script. The cc.html had **Apple Symbol encoding corruption** — `textutil` mapped bytes through Symbol encoding instead of PUA. Fixed by `fix-bengali-encoding.py` which remaps 356,396 characters back to PUA range U+F0xx.
### Devanagari generation
IAST transliteration → Devanagari via `indic-transliteration` library. Key fixes:
- `join_virama_words()` — joins virāma + consonant AND virāma + vowel (fixed 12,000+ rendering issues in SB)
- `normalize_devanagari_anusvara()` — ङ् → ं before velars, ञ् → ं before palatals
---
## Verse Navigation (verse-based books)
- **One verse per page** with path-based URLs: `/books/bg/1/5`, `/books/sb/6/6/1`, `/books/cc/adi/1/3`
- **ChapterViewer** (`app/books/chapter-viewer.tsx`): Client component with:
- Sidebar showing ALL verse numbers (from lightweight index query)
- Prev/Next buttons at top and bottom
- Keyboard navigation (← →)
- Client-side verse caching + prefetching adjacent verses
- Click any verse in sidebar → fetches from API and navigates
- **API route** supports: `?verse=N` (single), `?indexOnly=true` (lightweight index), `?page=&limit=` (paginated)
- Server component SSRs the first verse, client handles navigation
---
## KB (Kṛṣṇa Book) — Prose Book
Different from verse books — chapters are prose paragraphs, no verse structure:
- `ProseChapter` model with `paragraphs: String[]`
- 5 intro segments per volume (flap, dedication, George Harrison intro, preface, introduction)
- Sanskrit verses within prose are grouped into `<div style="text-align:center"><i>...</i></div>` blocks with `<br>` tags
- Reading page has left sidebar with chapter index, prev/next navigation
---
## Data Access Layer (`lib/api.ts`)
```typescript
// Book meta
getAllBooks(): Promise<BookMeta[]>
getBookMeta(book: string): Promise<BookMeta | null>
// Verse index (lightweight — for sidebar)
getChapterVerseIndex(book, division, chapter): Promise<VerseIndexEntry[]>
// Single verse
getSingleVerse(book, division, chapter, verse): Promise<VerseRecord | null>
// Paginated verses
getChapterVerses(book, division, chapter, page?, limit?): Promise<PaginatedVerses>
// Prose chapters (for KB)
getProseChapters(book, volume, type?): Promise<ProseChapterRecord[]>
getProseChapter(book, volume, slug): Promise<ProseChapterRecord | null>
// Glossary
getAllGlossaryEntries(): Promise<GlossaryRecord[]>
```
The data access layer includes `normalizeProse()` which handles mixed storage (some chapters stored with native arrays, some with stringified JSON due to BSON corruption workaround). It also includes `sanitizeHtml()` which strips `<script>`, `<style>`, `<iframe>`, event handlers from paragraphs.
---
## Known Issues
1. **MongoDB BSON wire corruption**: The MongoDB server (7.0.31) behind a proxy/tunnel occasionally corrupts large BSON documents during transmission. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time. The `insertMany` and large `insertOne` operations fail non-deterministically with "Invalid UTF-8 string" or "Unrecognized BSON type" errors. Retrying usually works.
2. **SB verse numbering**: The HTML had 5 duplicate `p20` verse reference tags. Fixed by using the `p21` TEXT N header as the authoritative verse number (corrects the wrong `p20` reference).
3. **SB 1962 chapter 15**: Verses 41-45 have different numbering between the 1962 edition and standard SB. The matching script skips these 5 verses (keeps original plain English transliteration).
4. **RTF routes gated**: `/rtfs` and `/rtfs/[slug]` only accessible when `ALLOW_RTF=true` env variable is set.
5. **`scripts/` excluded from tsconfig**: Added to `"exclude"` array to prevent TypeScript build errors from seed scripts.
6. **MongoDB requires no replica set for reads** but requires replica set for Prisma writes. All writes use the native MongoDB driver instead of Prisma.
---
## Environment Variables
```
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
ALLOW_RTF=true # optional — enables RTF viewer routes
```
---
## How to Run
```bash
# Development
npm run dev # Start dev server
# Prisma
npx prisma generate # Regenerate Prisma client after schema changes
# Extraction scripts (Python 3 + beautifulsoup4 + indic-transliteration)
python3 scripts/bg_extract.py # Extract BG → lib/jsons/books/bg/
python3 scripts/sb_extract.py # Extract SB → lib/jsons/books/sb/
python3 scripts/cc_extract.py # Extract CC → lib/jsons/books/cc/
python3 scripts/glossary_extract.py # Extract glossary → lib/jsons/glossary/
python3 scripts/sb1962_extract.py # Extract 1962 SB → lib/jsons/books/sb-1962/
python3 scripts/match_sb1962.py # Match 1962 SB with standard SB IAST
python3 scripts/kb_extract_intro.py # Extract KB intro → lib/jsons/books/kb/intro.json
python3 scripts/kb_extract_vol1_intro.py # KB Vol 1 front matter
python3 scripts/kb_extract_vol1_chapters.py # KB Vol 1 chapters
python3 scripts/kb_extract_vol2_chapters.py # KB Vol 2 chapters
# Database seeding (TypeScript + tsx)
npx tsx scripts/seed-db.ts # Seed all data to MongoDB
npx tsx scripts/seed-kb-vol2.ts # Seed KB Vol 2 (separate due to BSON issues)
# Build
npx next build # Production build
```
---
## URL Routes
| Route | Description |
|-------|-------------|
| `/` | Landing page — Books, Glossary, RTF Viewer cards |
| `/books` | All books index |
| `/books/bg` | BG chapter list |
| `/books/bg/[chapter]` | Redirects to first verse |
| `/books/bg/[chapter]/[verse]` | Single verse viewer |
| `/books/sb` | SB canto list |
| `/books/sb/[canto]` | Chapter list for a canto |
| `/books/sb/[canto]/[chapter]` | Redirects to first verse |
| `/books/sb/[canto]/[chapter]/[verse]` | Single verse viewer |
| `/books/cc` | CC līlā list |
| `/books/cc/[lila]` | Chapter list for a līlā |
| `/books/cc/[lila]/[chapter]` | Redirects to first verse |
| `/books/cc/[lila]/[chapter]/[verse]` | Single verse viewer |
| `/books/sb1962` | 1962 SB chapter list |
| `/books/sb1962/[chapter]` | Redirects to first verse |
| `/books/sb1962/[chapter]/[verse]` | Single verse viewer |
| `/books/kb` | KB volume list |
| `/books/kb/[volume]` | Chapter list + intro segments |
| `/books/kb/[volume]/[slug]` | Prose reading page with sidebar |
| `/glossary` | Searchable glossary (2,749 entries) |
| `/rtfs` | RTF file listing (requires ALLOW_RTF=true) |
| `/rtfs/[slug]` | RTF iframe viewer (requires ALLOW_RTF=true) |
| `/api/books` | GET all book metas |
| `/api/books/[book]` | GET single book meta |
| `/api/books/[book]/verses` | GET verses with query params |
| `/api/glossary` | GET all glossary entries |
---
## Verse JSON Schema (BG/SB/CC/SB1962)
```json
{
"book_title": "Śrīmad-Bhāgavatam",
"canto": 1,
"chapter": 1,
"verse": 1,
"verse_end": null,
"reference": "SB 1.1.1",
"devanagari": "ॐ नमो भगवते वासुदेवाय\nजन्माद्यस्य यतो ...",
"bengali": null,
"transliteration": "om namo bhagavate vāsudevāya\njanmādy asya yato ...",
"synonyms": [
{ "word": "om", "meanings": ["O my Lord"] },
{ "word": "namaḥ", "meanings": ["offering my obeisances"] }
],
"translation": "<b>I offer my obeisances unto Lord Śrī Kṛṣṇa...</b>",
"purport": ["Obeisances unto the Personality of Godhead...", "paragraph 2..."]
}
```
## Prose Chapter JSON Schema (KB)
```json
{
"book": "kb",
"volume": 1,
"chapter": 1,
"title": "Advent of Lord Kṛṣṇa",
"type": "chapter",
"paragraphs": [
"<b>1 /Advent of Lord Kṛṣṇa</b>",
"Once the world was overburdened...",
"<div style=\"text-align:center\"><i>verse line 1<br>verse line 2</i></div>",
"<i>Thus ends the Bhaktivedanta purport of the First Chapter of </i>Kṛṣṇa..."
]
}
```
## Intro Segment JSON Schema (KB)
```json
{
"slug": "words-from-apple",
"title": "Words from Apple — by George Harrison",
"type": "intro-segment",
"book": "kb",
"volume": 1,
"order": 3,
"paragraphs": ["<b>Words From Apple</b>", "Everybody is looking for KṚṢṆA.", "..."]
}
```
## Glossary JSON Schema
```json
{
"title": "Glossary of Vedic Terms",
"description": "A comprehensive glossary of Sanskrit and Vedic terms...",
"totalEntries": 2749,
"letters": ["A", "B", "C", ...],
"entries": [
{
"term": "Ācārya",
"definition": "a spiritual master who teaches by his own example...",
"definition_html": "a spiritual master who teaches by his own example..."
}
]
}
```
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getBbdContent,
getBbdSidebarItems,
type BbdChapter,
type BbdSidebarItem,
} from "@/lib/bbd";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function BbdChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getBbdContent(slug);
if (!content) notFound();
const sidebarItems = await getBbdSidebarItems();
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 BbdChapter).chapter === "number" &&
(content as BbdChapter).chapter !== null;
const chapterNum = isChapter ? (content as BbdChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/bbd"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Beyond Birth and Death
</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 */}
<BbdSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<BbdNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<BbdNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function BbdSidebar({
items,
currentSlug,
}: {
items: BbdSidebarItem[];
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: BbdSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/bbd/${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 BbdNav({
prev,
next,
}: {
prev: BbdSidebarItem | null;
next: BbdSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/bbd/${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 />
)}
{next ? (
<Link
href={`/books/bbd/${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 { getBbdMeta, getBbdIntroSegments } from "@/lib/bbd";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function BbdPage() {
const meta = await getBbdMeta();
if (!meta) notFound();
const introSegments = await getBbdIntroSegments();
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/bbd/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bbd/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"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";
import { ExtractedVerseCount } from "./extracted-verse-count";
export type BookInfo = {
slug: string;
title: string;
author: string;
description: string;
contentDescription: string;
totalVerses: number;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*
* Uses Unicode NFD decomposition to split base characters from
* combining marks, then removes the marks. Also handles special
* IAST characters that don't decompose cleanly via NFD.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
// Handle IAST characters that NFD doesn't decompose into ASCII base
.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")
// NFD decompose + strip combining marks (handles ā→a, ī→i, ū→u, etc.)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
export function BooksGrid({ books }: { books: BookInfo[] }) {
const [query, setQuery] = useState("");
const normalizedBooks = useMemo(
() =>
books.map((book) => ({
book,
searchText: normalize(
`${book.title} ${book.contentDescription} ${book.description} ${book.slug}`,
),
})),
[books],
);
const filtered = useMemo(() => {
if (!query.trim()) return books;
const q = normalize(query.trim());
return normalizedBooks
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.book);
}, [books, normalizedBooks, 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 books..."
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 books found"
: `${filtered.length} book${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((book) => (
<Link key={book.slug} href={book.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">
{book.title}
</CardTitle>
{book.contentDescription && (
<CardDescription className="text-xs line-clamp-2">
{book.contentDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{book.description && (
<Badge variant="secondary" className="text-xs">
{book.description}
</Badge>
)}
{book.totalVerses > 0 && (
<ExtractedVerseCount
count={book.totalVerses}
label="verses"
className="text-muted-foreground text-xs"
/>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -148,7 +148,7 @@ function VerseCard({
{/* Devanagari */}
<div className="text-center">
<p className="text-xl leading-relaxed text-red-800 dark:text-red-400 whitespace-pre-line font-serif">
<p className="text-xl leading-relaxed text-red-800 dark:text-white whitespace-pre-line font-serif">
{verse.devanagari}
</p>
</div>
+250
View File
@@ -0,0 +1,250 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getDsContent,
getDsSidebarItems,
type DsChapter,
type DsSidebarItem,
} from "@/lib/ds";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function DsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getDsContent(slug);
if (!content) notFound();
const sidebarItems = await getDsSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
const isSubChapter =
"parentChapter" in content &&
typeof (content as DsChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as DsChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/ds"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Dialectic Spiritualism
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<DsSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<DsNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<DsNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function DsSidebar({
items,
currentSlug,
}: {
items: DsSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const introItems = items.filter((i) => i.type === "intro-segment");
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-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 — grouped by parent */}
{parents.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 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/ds/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: DsSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ds/${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 DsNav({
prev,
next,
}: {
prev: DsSidebarItem | null;
next: DsSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/ds/${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 />
)}
{next ? (
<Link
href={`/books/ds/${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>
);
}
+100
View File
@@ -0,0 +1,100 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getDsMeta, getDsIntroSegments } from "@/lib/ds";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function DsPage() {
const meta = await getDsMeta();
if (!meta) notFound();
const introSegments = await getDsIntroSegments();
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>
{meta.subtitle && (
<p className="mt-1 text-lg text-muted-foreground">{meta.subtitle}</p>
)}
<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.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/ds/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ds/${sub.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getEjContent,
getEjSidebarItems,
type EjChapter,
type EjSidebarItem,
} from "@/lib/ej";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EjChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getEjContent(slug);
if (!content) notFound();
const sidebarItems = await getEjSidebarItems();
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 EjChapter).chapter === "number" &&
(content as EjChapter).chapter !== null;
const chapterNum = isChapter ? (content as EjChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/ej"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Easy Journey to Other Planets
</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 */}
<EjSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EjNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EjNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EjSidebar({
items,
currentSlug,
}: {
items: EjSidebarItem[];
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: EjSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ej/${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 EjNav({
prev,
next,
}: {
prev: EjSidebarItem | null;
next: EjSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/ej/${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 />
)}
{next ? (
<Link
href={`/books/ej/${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>
);
}
+103
View File
@@ -0,0 +1,103 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEjMeta, getEjIntroSegments } from "@/lib/ej";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function EjPage() {
const meta = await getEjMeta();
if (!meta) notFound();
const introSegments = await getEjIntroSegments();
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-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<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/ej/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ej/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getEkContent,
getEkSidebarItems,
type EkChapter,
type EkSidebarItem,
} from "@/lib/ek";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getEkContent(slug);
if (!content) notFound();
const sidebarItems = await getEkSidebarItems();
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 EkChapter).chapter === "number" &&
(content as EkChapter).chapter !== null;
const chapterNum = isChapter ? (content as EkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/ek"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Elevation to Kṛṣṇa Consciousness
</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 */}
<EkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EkNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EkNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EkSidebar({
items,
currentSlug,
}: {
items: EkSidebarItem[];
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: EkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ek/${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 EkNav({
prev,
next,
}: {
prev: EkSidebarItem | null;
next: EkSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/ek/${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 />
)}
{next ? (
<Link
href={`/books/ek/${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 { getEkMeta, getEkIntroSegments } from "@/lib/ek";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function EkPage() {
const meta = await getEkMeta();
if (!meta) notFound();
const introSegments = await getEkIntroSegments();
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/ek/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ek/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getHkhContent,
getHkhSidebarItems,
type HkhChapter,
type HkhSidebarItem,
} from "@/lib/hkh";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function HkhChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getHkhContent(slug);
if (!content) notFound();
const sidebarItems = await getHkhSidebarItems();
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 HkhChapter).chapter === "number" &&
(content as HkhChapter).chapter !== null;
const chapterNum = isChapter ? (content as HkhChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/hkh"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Kṛṣṇa Consciousness Handbook
</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 */}
<HkhSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<HkhNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<HkhNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function HkhSidebar({
items,
currentSlug,
}: {
items: HkhSidebarItem[];
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: HkhSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/hkh/${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 HkhNav({
prev,
next,
}: {
prev: HkhSidebarItem | null;
next: HkhSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/hkh/${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 />
)}
{next ? (
<Link
href={`/books/hkh/${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 { getHkhMeta, getHkhIntroSegments } from "@/lib/hkh";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function HkhPage() {
const meta = await getHkhMeta();
if (!meta) notFound();
const introSegments = await getHkhIntroSegments();
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/hkh/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/hkh/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getIsoContent,
getIsoSidebarItems,
type IsoVerse,
type IsoIntroSegment,
type IsoSidebarItem,
} from "@/lib/iso";
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";
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
function isVerse(content: IsoVerse | IsoIntroSegment): content is IsoVerse {
return "mantra" in content && "devanagari" in content;
}
function isIntroSegment(
content: IsoVerse | IsoIntroSegment,
): content is IsoIntroSegment {
return "paragraphs" in content && "order" in content;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function IsoMantraPage({
params,
}: {
params: Promise<{ mantra: string }>;
}) {
const { mantra: slug } = await params;
const content = await getIsoContent(slug);
if (!content) notFound();
const sidebarItems = await getIsoSidebarItems();
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 for the header
const verse = isVerse(content) ? content : null;
const intro = isIntroSegment(content) ? content : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/iso"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Īśopaniṣad
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">{verse.reference}: </span>
{verse.title}
</>
)}
{intro && intro.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<IsoSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<IsoNav prev={prev} next={next} />
{/* Content */}
{verse && <VerseContent verse={verse} />}
{intro && <IntroContent intro={intro} />}
{/* Bottom navigation */}
<IsoNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
// ---------------------------------------------------------------------------
function VerseContent({ verse }: { verse: IsoVerse }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
{/* Devanagari */}
<section>
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
{verse.devanagari}
</div>
</section>
{/* Transliteration */}
<section>
<div className="text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
{verse.transliteration}
</div>
</section>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Synonyms
</h2>
<div className="text-sm text-foreground/70 leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
{i > 0 && "; "}
<span className="font-medium text-foreground/90">
{s.word}
</span>
{"—"}
{s.meanings.join(", ")}
</span>
))}
</div>
</section>
)}
{/* Translation */}
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Translation
</h2>
<div
className="text-foreground font-medium leading-relaxed"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</section>
{/* Purport */}
{verse.purport.length > 0 && (
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Purport
</h2>
<div className="space-y-4">
{verse.purport.map((p, i) => (
<div
key={i}
className="text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: p }}
/>
))}
</div>
</section>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Intro segment content (paragraphs)
// ---------------------------------------------------------------------------
function IntroContent({ intro }: { intro: IsoIntroSegment }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{intro.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function IsoSidebar({
items,
currentSlug,
}: {
items: IsoSidebarItem[];
currentSlug: string;
}) {
// Front matter items (front-pages + introduction)
const frontMatterItems = items.filter(
(item) =>
item.slug === "intro-front-pages" || item.slug === "intro-introduction",
);
// Mantras = invocation + 1-18
const mantraItems = items.filter((item) => item.type === "verse");
// about-the-author comes last
const aboutAuthorItem = items.find(
(item) => item.slug === "intro-about-the-author",
);
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 */}
{frontMatterItems.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">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Mantras */}
{mantraItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Mantras
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{mantraItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* About the Author */}
{aboutAuthorItem && (
<nav className="flex flex-col gap-0.5">
<SidebarLink
item={aboutAuthorItem}
isActive={aboutAuthorItem.slug === currentSlug}
/>
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: IsoSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/iso/${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 IsoNav({
prev,
next,
}: {
prev: IsoSidebarItem | null;
next: IsoSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/iso/${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 === "verse" && prev.mantra != null && prev.mantra > 0
? `Mantra ${prev.mantra}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div />
)}
{next ? (
<Link
href={`/books/iso/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "verse" && next.mantra != null && next.mantra > 0
? `Mantra ${next.mantra}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIsoMeta, getIsoIntroSegments } from "@/lib/iso";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function IsoPage() {
const meta = await getIsoMeta();
if (!meta) notFound();
const introSegments = await getIsoIntroSegments();
const frontPages = introSegments.find((s) => s.slug === "front-pages");
const introduction = introSegments.find((s) => s.slug === "introduction");
const aboutAuthor = introSegments.find((s) => s.slug === "about-the-author");
// Invocation is mantra 0, mantras are 1-18
const invocation = meta.mantras.find((m) => m.number === 0);
const numberedMantras = meta.mantras.filter((m) => m.number > 0);
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 */}
{(frontPages || introduction) && (
<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">
{frontPages && (
<li>
<Link
href="/books/iso/intro-front-pages"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{frontPages.paragraphs.length} paragraphs
</span>
</Link>
</li>
)}
{introduction && (
<li>
<Link
href="/books/iso/intro-introduction"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{introduction.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Mantras */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Mantras
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{/* Invocation */}
{invocation && (
<li>
<Link
href="/books/iso/invocation"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Invocation
</span>
<p className="font-medium text-foreground">
{invocation.title}
</p>
</div>
</Link>
</li>
)}
{/* Mantras 1-18 */}
{numberedMantras.map((m) => (
<li key={m.number}>
<Link
href={`/books/iso/${m.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Mantra {m.number}
</span>
<p className="font-medium text-foreground">{m.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{aboutAuthor && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
About the Author
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/iso/intro-about-the-author"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{aboutAuthor.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{aboutAuthor.paragraphs.length} paragraphs
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getMgContent,
getMgSidebarItems,
type MgChapter,
type MgSidebarItem,
} from "@/lib/mg";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function MgChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getMgContent(slug);
if (!content) notFound();
const sidebarItems = await getMgSidebarItems();
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 MgChapter).chapter === "number" &&
(content as MgChapter).chapter !== null;
const chapterNum = isChapter ? (content as MgChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/mg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Kṛṣṇa Consciousness: The Matchless Gift
</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 */}
<MgSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MgNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<MgNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function MgSidebar({
items,
currentSlug,
}: {
items: MgSidebarItem[];
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: MgSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/mg/${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 MgNav({
prev,
next,
}: {
prev: MgSidebarItem | null;
next: MgSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/mg/${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 />
)}
{next ? (
<Link
href={`/books/mg/${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 { getMgMeta, getMgIntroSegments } from "@/lib/mg";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function MgPage() {
const meta = await getMgMeta();
if (!meta) notFound();
const introSegments = await getMgIntroSegments();
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/mg/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mg/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getNodContent,
getNodSidebarItems,
type NodChapter,
type NodSidebarItem,
} from "@/lib/nod";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function NodChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getNodContent(slug);
if (!content) notFound();
const sidebarItems = await getNodSidebarItems();
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 NodChapter).chapter === "number" &&
(content as NodChapter).chapter !== null;
const chapterNum = isChapter ? (content as NodChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/nod"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Devotion
</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 */}
<NodSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<NodNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<NodNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function NodSidebar({
items,
currentSlug,
}: {
items: NodSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
const conclusionItems = items.filter((item) => item.type === "conclusion");
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 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Concluding Words */}
{conclusionItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{conclusionItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: NodSidebarItem;
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/nod/${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 NodNav({
prev,
next,
}: {
prev: NodSidebarItem | null;
next: NodSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/nod/${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 />
)}
{next ? (
<Link
href={`/books/nod/${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>
);
}
+143
View File
@@ -0,0 +1,143 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getNodMeta, getNodIntroSegments } from "@/lib/nod";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
const PART_LABELS: Record<number, string> = {
1: "Part One",
2: "Part Two",
3: "Part Three",
4: "Part Four",
};
export default async function NodPage() {
const meta = await getNodMeta();
if (!meta) notFound();
const introSegments = await getNodIntroSegments();
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-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<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/nod/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nod/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nod/concluding-words"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getNoiContent,
getNoiSidebarItems,
type NoiVerse,
type NoiIntroSegment,
type NoiSidebarItem,
} from "@/lib/noi";
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";
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
function isVerse(content: NoiVerse | NoiIntroSegment): content is NoiVerse {
return "text" in content && "devanagari" in content;
}
function isIntroSegment(
content: NoiVerse | NoiIntroSegment,
): content is NoiIntroSegment {
return "paragraphs" in content && "order" in content;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function NoiTextPage({
params,
}: {
params: Promise<{ text: string }>;
}) {
const { text: slug } = await params;
const content = await getNoiContent(slug);
if (!content) notFound();
const sidebarItems = await getNoiSidebarItems();
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 for the header
const verse = isVerse(content) ? content : null;
const intro = isIntroSegment(content) ? content : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/noi"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Instruction
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">Text {verse.text}: </span>
{verse.title}
</>
)}
{intro && intro.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<NoiSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<NoiNav prev={prev} next={next} />
{/* Content */}
{verse && <VerseContent verse={verse} />}
{intro && <IntroContent intro={intro} />}
{/* Bottom navigation */}
<NoiNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
// ---------------------------------------------------------------------------
function VerseContent({ verse }: { verse: NoiVerse }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
{/* Devanagari */}
<section>
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
{verse.devanagari}
</div>
</section>
{/* Transliteration */}
<section>
<div className="text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
{verse.transliteration}
</div>
</section>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Synonyms
</h2>
<div className="text-sm text-foreground/70 leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
{i > 0 && "; "}
<span className="font-medium text-foreground/90">
{s.word}
</span>
{"—"}
{s.meanings.join(", ")}
</span>
))}
</div>
</section>
)}
{/* Translation */}
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Translation
</h2>
<div
className="text-foreground font-medium leading-relaxed"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</section>
{/* Purport */}
{verse.purport.length > 0 && (
<section>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
Purport
</h2>
<div className="space-y-4">
{verse.purport.map((p, i) => (
<div
key={i}
className="text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: p }}
/>
))}
</div>
</section>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Intro segment content (paragraphs)
// ---------------------------------------------------------------------------
function IntroContent({ intro }: { intro: NoiIntroSegment }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{intro.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function NoiSidebar({
items,
currentSlug,
}: {
items: NoiSidebarItem[];
currentSlug: string;
}) {
// Front matter items (front-pages + preface)
const frontMatterItems = items.filter(
(item) =>
item.slug === "intro-front-pages" || item.slug === "intro-preface",
);
// Texts = 1-11
const textItems = items.filter((item) => item.type === "verse");
// Appendixes comes last
const appendixesItem = items.find(
(item) => item.slug === "intro-appendixes",
);
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 */}
{frontMatterItems.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">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Texts */}
{textItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Texts
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{textItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Appendixes */}
{appendixesItem && (
<nav className="flex flex-col gap-0.5">
<SidebarLink
item={appendixesItem}
isActive={appendixesItem.slug === currentSlug}
/>
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: NoiSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/noi/${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 NoiNav({
prev,
next,
}: {
prev: NoiSidebarItem | null;
next: NoiSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/noi/${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 === "verse" && prev.text != null
? `Text ${prev.text}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div />
)}
{next ? (
<Link
href={`/books/noi/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "verse" && next.text != null
? `Text ${next.text}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getNoiMeta, getNoiIntroSegments } from "@/lib/noi";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function NoiPage() {
const meta = await getNoiMeta();
if (!meta) notFound();
const introSegments = await getNoiIntroSegments();
const frontPages = introSegments.find((s) => s.slug === "front-pages");
const preface = introSegments.find((s) => s.slug === "preface");
const appendixes = introSegments.find((s) => s.slug === "appendixes");
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 italic text-muted-foreground">{meta.subtitle}</p>
<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 */}
{(frontPages || preface) && (
<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">
{frontPages && (
<li>
<Link
href="/books/noi/intro-front-pages"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{frontPages.paragraphs.length} paragraphs
</span>
</Link>
</li>
)}
{preface && (
<li>
<Link
href="/books/noi/intro-preface"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{preface.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{preface.paragraphs.length} paragraphs
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.texts.map((t) => (
<li key={t.number}>
<Link
href={`/books/noi/${t.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {t.number}
</span>
<p className="font-medium text-foreground">{t.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Appendixes */}
{appendixes && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Appendixes
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/noi/intro-appendixes"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{appendixes.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{appendixes.paragraphs.length} paragraphs
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOchkmContent,
getOchkmSidebarItems,
type OchkmChapter,
type OchkmSidebarItem,
} from "@/lib/ochkm";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OchkmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getOchkmContent(slug);
if (!content) notFound();
const sidebarItems = await getOchkmSidebarItems();
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 OchkmChapter).chapter === "number" &&
(content as OchkmChapter).chapter !== null;
const chapterNum = isChapter ? (content as OchkmChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/ochkm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On Chanting the Hare Kṛṣṇa Mantra
</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 */}
<OchkmSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<OchkmNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<OchkmNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function OchkmSidebar({
items,
currentSlug,
}: {
items: OchkmSidebarItem[];
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: OchkmSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ochkm/${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 OchkmNav({
prev,
next,
}: {
prev: OchkmSidebarItem | null;
next: OchkmSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/ochkm/${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 />
)}
{next ? (
<Link
href={`/books/ochkm/${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 { getOchkmMeta, getOchkmIntroSegments } from "@/lib/ochkm";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function OchkmPage() {
const meta = await getOchkmMeta();
if (!meta) notFound();
const introSegments = await getOchkmIntroSegments();
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/ochkm/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ochkm/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOwkContent,
getOwkSidebarItems,
type OwkChapter,
type OwkSidebarItem,
} from "@/lib/owk";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OwkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getOwkContent(slug);
if (!content) notFound();
const sidebarItems = await getOwkSidebarItems();
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 OwkChapter).chapter === "number" &&
(content as OwkChapter).chapter !== null;
const chapterNum = isChapter ? (content as OwkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/owk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On the Way to Kṛṣṇa
</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 */}
<OwkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<OwkNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<OwkNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function OwkSidebar({
items,
currentSlug,
}: {
items: OwkSidebarItem[];
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: OwkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/owk/${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 OwkNav({
prev,
next,
}: {
prev: OwkSidebarItem | null;
next: OwkSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/owk/${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 />
)}
{next ? (
<Link
href={`/books/owk/${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 { getOwkMeta, getOwkIntroSegments } from "@/lib/owk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function OwkPage() {
const meta = await getOwkMeta();
if (!meta) notFound();
const introSegments = await getOwkIntroSegments();
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/owk/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/owk/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+34 -62
View File
@@ -1,16 +1,7 @@
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 { cn } from "@/lib/utils";
import { ExtractedVerseCount } from "./extracted-verse-count";
import { getAllBooks, type BookMeta } from "@/lib/api";
import { BooksGrid, type BookInfo } from "./books-grid";
type ChapterEntry = { totalVerses: number };
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
@@ -21,12 +12,6 @@ interface ChapterBookMeta extends BookMeta {
chapters: ChapterEntry[];
}
interface KbMeta extends BookMeta {
totalChapters: number;
description: string;
volumes: { number: number; totalChapters: number }[];
}
interface SbMeta extends BookMeta {
totalCantos: number;
cantos: CantoEntry[];
@@ -37,15 +22,6 @@ interface CcMeta extends BookMeta {
lilas: LilaEntry[];
}
type BookInfo = {
slug: string;
title: string;
author: string;
description: string;
totalVerses: number;
href: string;
};
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
const books: BookInfo[] = [];
@@ -65,6 +41,8 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
title,
author,
description: `${bg.totalChapters} chapters`,
contentDescription:
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
totalVerses,
href: "/books/bg",
});
@@ -79,6 +57,9 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
title,
author,
description: `${sb62.totalChapters} chapters · First Canto`,
contentDescription:
(data as { description?: string }).description ||
"The original 1962 Indian edition of the First Canto.",
totalVerses,
href: "/books/sb1962",
});
@@ -97,6 +78,8 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
title,
author,
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
contentDescription:
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
totalVerses,
href: "/books/sb",
});
@@ -115,18 +98,37 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
title,
author,
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
contentDescription:
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
totalVerses,
href: "/books/cc",
});
} else if (slug === "kb") {
const kb = data as KbMeta;
} else {
// Generic handler for all other books — pulls description from metadata
const metaDesc = (data as { description?: string }).description || "";
const totalChapters = (data as { totalChapters?: number }).totalChapters;
const totalMantras = (data as { totalMantras?: number }).totalMantras;
const totalTexts = (data as { totalTexts?: number }).totalTexts;
const parts = (data as { parts?: unknown[] }).parts;
const volumes = (data as { volumes?: unknown[] }).volumes;
let badge = "";
if (totalMantras) badge = `${totalMantras} mantras`;
else if (totalTexts) badge = `${totalTexts} texts`;
else if (totalChapters && volumes)
badge = `${totalChapters} chapters · ${volumes.length} volumes`;
else if (totalChapters && parts)
badge = `${totalChapters} chapters · ${parts.length} parts`;
else if (totalChapters) badge = `${totalChapters} chapters`;
books.push({
slug,
title,
author,
description: `${kb.totalChapters} chapters · 2 volumes`,
description: badge,
contentDescription: metaDesc,
totalVerses: 0,
href: "/books/kb",
href: `/books/${slug}`,
});
}
}
@@ -149,42 +151,12 @@ export default async function BooksPage() {
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
<p className="mt-1 text-muted-foreground">
Structured verse-by-verse editions with Devanagari, transliteration,
synonyms, translation &amp; purports
Browse the books and teachings of Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="grid gap-4">
{books.map((book) => (
<Link key={book.slug} href={book.href} className="block">
<Card
className={cn(
"transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader>
<CardTitle className="text-xl font-bold">
{book.title}
</CardTitle>
<CardDescription>{book.author}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{book.description}</Badge>
{book.totalVerses > 0 && (
<ExtractedVerseCount
count={book.totalVerses}
label="extracted verses"
className="text-muted-foreground"
/>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
<main className="max-w-6xl mx-auto px-6 py-8">
<BooksGrid books={books} />
</main>
</div>
);
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPoyContent,
getPoySidebarItems,
type PoyChapter,
type PoySidebarItem,
} from "@/lib/poy";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PoyChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPoyContent(slug);
if (!content) notFound();
const sidebarItems = await getPoySidebarItems();
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 PoyChapter).chapter === "number" &&
(content as PoyChapter).chapter !== null;
const chapterNum = isChapter ? (content as PoyChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/poy"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Perfection of Yoga
</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 */}
<PoySidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PoyNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PoyNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PoySidebar({
items,
currentSlug,
}: {
items: PoySidebarItem[];
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: PoySidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/poy/${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 PoyNav({
prev,
next,
}: {
prev: PoySidebarItem | null;
next: PoySidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/poy/${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 />
)}
{next ? (
<Link
href={`/books/poy/${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 { getPoyMeta, getPoyIntroSegments } from "@/lib/poy";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function PoyPage() {
const meta = await getPoyMeta();
if (!meta) notFound();
const introSegments = await getPoyIntroSegments();
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/poy/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/poy/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+283
View File
@@ -0,0 +1,283 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPqpaContent,
getPqpaSidebarItems,
type PqpaChapter,
type PqpaSidebarItem,
} from "@/lib/pqpa";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PqpaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPqpaContent(slug);
if (!content) notFound();
const sidebarItems = await getPqpaSidebarItems();
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 PqpaChapter).chapter === "number" &&
(content as PqpaChapter).chapter !== null;
const chapterNum = isChapter ? (content as PqpaChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/pqpa"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Perfect Questions Perfect Answers
</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 */}
<PqpaSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PqpaNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PqpaNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PqpaSidebar({
items,
currentSlug,
}: {
items: PqpaSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Conclusion: conclusion items
// Back matter: intro-segment items after conclusion (about-the-author)
const frontMatterItems: PqpaSidebarItem[] = [];
const chapterItems: PqpaSidebarItem[] = [];
const conclusionItems: PqpaSidebarItem[] = [];
const backMatterItems: PqpaSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (item.type === "conclusion") {
pastChapters = true;
conclusionItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
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 */}
{frontMatterItems.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">
{frontMatterItems.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 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Concluding Words */}
{conclusionItems.length > 0 && (
<nav className="flex flex-col gap-0.5 mb-4">
{conclusionItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
{/* About the Author */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PqpaSidebarItem;
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/pqpa/${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 PqpaNav({
prev,
next,
}: {
prev: PqpaSidebarItem | null;
next: PqpaSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/pqpa/${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 />
)}
{next ? (
<Link
href={`/books/pqpa/${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>
);
}
+161
View File
@@ -0,0 +1,161 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPqpaMeta, getPqpaIntroSegments } from "@/lib/pqpa";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function PqpaPage() {
const meta = await getPqpaMeta();
if (!meta) notFound();
const introSegments = await getPqpaIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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 */}
{frontMatter.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">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pqpa/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/pqpa/concluding-words"
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getRvContent,
getRvSidebarItems,
type RvChapter,
type RvSidebarItem,
} from "@/lib/rv";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function RvChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getRvContent(slug);
if (!content) notFound();
const sidebarItems = await getRvSidebarItems();
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 RvChapter).chapter === "number" &&
(content as RvChapter).chapter !== null;
const chapterNum = isChapter ? (content as RvChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/rv"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Rāja-vidyā: The King of Knowledge
</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 */}
<RvSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<RvNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<RvNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function RvSidebar({
items,
currentSlug,
}: {
items: RvSidebarItem[];
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: RvSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/rv/${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 RvNav({
prev,
next,
}: {
prev: RvSidebarItem | null;
next: RvSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/rv/${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 />
)}
{next ? (
<Link
href={`/books/rv/${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 { getRvMeta, getRvIntroSegments } from "@/lib/rv";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function RvPage() {
const meta = await getRvMeta();
if (!meta) notFound();
const introSegments = await getRvIntroSegments();
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/rv/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/rv/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getSsrContent,
getSsrSidebarItems,
type SsrChapter,
type SsrSidebarItem,
} from "@/lib/ssr";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function SsrChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getSsrContent(slug);
if (!content) notFound();
const sidebarItems = await getSsrSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
const isSubChapter =
"parentChapter" in content &&
typeof (content as SsrChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as SsrChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/ssr"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Science of Self-Realization
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<SsrSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<SsrNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<SsrNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function SsrSidebar({
items,
currentSlug,
}: {
items: SsrSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const frontMatter = items.filter(
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
);
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
const backMatter = items.filter((i) => i.slug === "intro-about-the-author");
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 */}
{frontMatter.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">
{frontMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by parent */}
{parents.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 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/ssr/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: SsrSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ssr/${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 SsrNav({
prev,
next,
}: {
prev: SsrSidebarItem | null;
next: SsrSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/ssr/${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 />
)}
{next ? (
<Link
href={`/books/ssr/${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>
);
}
+133
View File
@@ -0,0 +1,133 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSsrMeta, getSsrIntroSegments } from "@/lib/ssr";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function SsrPage() {
const meta = await getSsrMeta();
if (!meta) notFound();
const introSegments = await getSsrIntroSegments();
// Split intro segments into front matter (before chapters) and back matter (after)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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 */}
{frontMatter.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">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ssr/${sub.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTlcContent,
getTlcSidebarItems,
type TlcChapter,
type TlcSidebarItem,
} from "@/lib/tlc";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TlcChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTlcContent(slug);
if (!content) notFound();
const sidebarItems = await getTlcSidebarItems();
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 TlcChapter).chapter === "number" &&
(content as TlcChapter).chapter !== null;
const chapterNum = isChapter ? (content as TlcChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/tlc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Caitanya
</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 */}
<TlcSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TlcNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TlcNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TlcSidebar({
items,
currentSlug,
}: {
items: TlcSidebarItem[];
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: TlcSidebarItem;
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/tlc/${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 TlcNav({
prev,
next,
}: {
prev: TlcSidebarItem | null;
next: TlcSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/tlc/${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 />
)}
{next ? (
<Link
href={`/books/tlc/${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>
);
}
+116
View File
@@ -0,0 +1,116 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTlcMeta, getTlcIntroSegments } from "@/lib/tlc";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
const PART_LABELS: Record<number, string> = {
1: "Part I",
2: "Part II",
};
export default async function TlcPage() {
const meta = await getTlcMeta();
if (!meta) notFound();
const introSegments = await getTlcIntroSegments();
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-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<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/tlc/${seg.slug}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlc/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
</main>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTtpContent,
getTtpSidebarItems,
type TtpChapter,
type TtpSidebarItem,
} from "@/lib/ttp";
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";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TtpChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTtpContent(slug);
if (!content) notFound();
const sidebarItems = await getTtpSidebarItems();
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 TtpChapter).chapter === "number" &&
(content as TtpChapter).chapter !== null;
const chapterNum = isChapter ? (content as TtpChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/ttp"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcendental Teachings of Prahlāda Mahārāja
</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 */}
<TtpSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TtpNav prev={prev} next={next} />
{/* 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-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TtpNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TtpSidebar({
items,
currentSlug,
}: {
items: TtpSidebarItem[];
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: TtpSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ttp/${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 TtpNav({
prev,
next,
}: {
prev: TtpSidebarItem | null;
next: TtpSidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/books/ttp/${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 />
)}
{next ? (
<Link
href={`/books/ttp/${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 { getTtpMeta } from "@/lib/ttp";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function TtpPage() {
const meta = await getTtpMeta();
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/ttp/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
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 { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// 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);
}
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
type EssayData = {
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
paragraphs: string[];
};
type SidebarItem = {
slug: string;
title: string;
};
async function getEssay(slug: string): Promise<EssayData | null> {
const doc = await prisma.essay.findUnique({ where: { slug } });
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
author: doc.author,
year: doc.year,
group: doc.group,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
async function getEssaySidebar(): Promise<SidebarItem[]> {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true },
});
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EssayPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const essay = await getEssay(slug);
if (!essay) notFound();
const allEssays = await getEssaySidebar();
const currentIdx = allEssays.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? allEssays[currentIdx - 1] : null;
const next =
currentIdx < allEssays.length - 1 ? allEssays[currentIdx + 1] : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/essays"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Essays
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{essay.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<EssaySidebar items={allEssays} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EssayNav prev={prev} next={next} />
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{essay.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EssayNav prev={prev} next={next} />
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EssaySidebar({
items,
currentSlug,
}: {
items: SidebarItem[];
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">
Essays
</h2>
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<Link
key={item.slug}
href={`/essays/${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.title}
>
{item.title}
</Link>
))}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EssayNav({
prev,
next,
}: {
prev: SidebarItem | null;
next: SidebarItem | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/essays/${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.title}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div />
)}
{next ? (
<Link
href={`/essays/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.title}
</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 { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { prisma } from "@/lib/db";
async function getAllEssays() {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true, author: true, year: true, group: true },
});
}
export default async function EssaysPage() {
const essays = await getAllEssays();
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">Essays</h1>
<p className="mt-1 text-muted-foreground">
Standalone essays by Śrīla Prabhupāda
</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 items-center justify-between px-5 py-4 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>
</div>
);
}
+29 -2
View File
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/page-header";
import { BookOpen, FileText, BookA } from "lucide-react";
import { BookOpen, FileText, BookA, PenLine } from "lucide-react";
const allowRtf = process.env.ALLOW_RTF === "true";
@@ -53,6 +53,32 @@ export default function Home() {
</Card>
</Link>
{/* Essays */}
<Link href="/essays" 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">
<PenLine className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Essays</CardTitle>
<CardDescription>
Standalone essays by Śrīla Prabhupāda
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Foundational essays including &ldquo;Krishna The Reservoir Of
Pleasure&rdquo; and &ldquo;Who Is Crazy?&rdquo; — timeless
introductions to the science of Kṛṣṇa consciousness.
</p>
</CardContent>
</Card>
</Link>
{/* Glossary */}
<Link href="/glossary" className="block group">
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
@@ -95,7 +121,8 @@ export default function Home() {
RTF Document Viewer
</CardTitle>
<CardDescription>
17 original documents from the original unedited Vedabase
17 original documents from the original unedited
Vedabase
</CardDescription>
</div>
</div>
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for Beyond Birth and Death (BBD).
*
* Reads from MongoDB via Prisma.
* BBD is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
*
* 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 = "bbd";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BbdMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: BbdChapterEntry[];
};
export type BbdChapterEntry = {
number: number;
title: string;
};
export type BbdIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type BbdChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BbdSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getBbdMeta(): Promise<BbdMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as BbdMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getBbdIntroSegment(
slug: string,
): Promise<BbdIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getBbdChapter(
chapterNum: number,
): Promise<BbdChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getBbdContent(
slug: string,
): Promise<BbdChapter | BbdIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getBbdIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getBbdChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getBbdMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: BbdSidebarItem[] = [];
// Intro segments (from DB, in order)
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–5 (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;
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Data access layer for Dialectic Spiritualism (DS).
*
* Reads from MongoDB via Prisma.
* DS is a large prose/dialogue book with 3 intro segments (front-pages,
* foreword, preface) and 13 parent chapters, each containing philosopher
* sub-chapters (e.g., "1-1" Socrates, "1-2" Plato, "1-3" Aristotle).
* 38 philosopher sub-chapters total.
*
* 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 = "ds";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DsMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: DsParentChapter[];
};
export type DsParentChapter = {
number: number;
title: string;
subChapters: DsSubChapterEntry[];
};
export type DsSubChapterEntry = {
slug: string; // "1-1", "1-2", etc.
title: string;
};
export type DsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type DsChapter = {
book: string;
chapter: string; // "1-1", "2-1", etc.
parentChapter: number;
subChapter: number;
title: string;
parentTitle: string;
type: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type DsSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "parent" | "sub-chapter" */
type: string;
/** Parent chapter number, or null for intro segments */
parentChapter: 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;
}
/** 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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
*/
export async function getDsMeta(): Promise<DsMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as DsMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getDsIntroSegments(): Promise<DsIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getDsIntroSegment(
slug: string,
): Promise<DsIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters (sub-chapters like "1-1", "2-3", etc.)
// ---------------------------------------------------------------------------
/**
* A sub-chapter by its slug (e.g. "1-1", "2-3").
*/
export async function getDsChapter(slug: string): Promise<DsChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug,
},
},
});
if (!doc) return null;
// Parse parentChapter and subChapter from slug "X-Y"
const parts = slug.split("-");
const parentChapter = parseInt(parts[0], 10);
const subChapter = parseInt(parts[1], 10);
// Derive parentTitle from meta if available
const meta = await getDsMeta();
const parentEntry = meta?.chapters.find((ch) => ch.number === parentChapter);
const parentTitle = parentEntry?.title ?? "";
return {
book: doc.book,
chapter: slug,
parentChapter,
subChapter,
title: doc.title,
parentTitle,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-foreword" → intro segment
* "intro-preface" → intro segment
* "1-1", "2-3", etc. → sub-chapter
*/
export async function getDsContent(
slug: string,
): Promise<DsChapter | DsIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getDsIntroSegment(slug);
}
// Sub-chapter slug like "1-1", "2-3"
return getDsChapter(slug);
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list with hierarchical structure.
*
* Order: front-pages → foreword → preface
* → parent-1 (label) → 1-1, 1-2, 1-3 → parent-2 (label) → 2-1, 2-2, ...
*
* "parent" type items are non-clickable headings. Only "intro-segment" and
* "sub-chapter" items are navigable pages.
*/
export async function getDsSidebarItems(): Promise<DsSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getDsMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
if (!meta) return [];
const items: DsSidebarItem[] = [];
// Intro segments (all come before chapters — no back matter for DS)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
parentChapter: null,
});
}
}
// Chapters — grouped by parent chapter with sub-chapters
for (const ch of meta.chapters) {
// Parent heading (non-clickable label)
items.push({
slug: `parent-${ch.number}`,
label: `${ch.number}. ${ch.title}`,
type: "parent",
parentChapter: ch.number,
});
// Sub-chapters under this parent
for (const sub of ch.subChapters) {
items.push({
slug: sub.slug,
label: sub.title,
type: "sub-chapter",
parentChapter: ch.number,
});
}
}
return items;
}
/**
* Get the flat list of navigable sidebar items (excludes "parent" headings).
* Used for prev/next navigation.
*/
export function getNavigableItems(items: DsSidebarItem[]): DsSidebarItem[] {
return items.filter((item) => item.type !== "parent");
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Data access layer for Easy Journey to Other Planets (EJ).
*
* Reads from MongoDB via Prisma.
* EJ is a simple prose book with 2 chapters and 2 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 = "ej";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EjMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: EjChapterEntry[];
};
export type EjChapterEntry = {
number: number;
title: string;
};
export type EjIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type EjChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type EjSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", "2" */
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/nod.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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getEjMeta(): Promise<EjMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as EjMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getEjIntroSegment(
slug: string,
): Promise<EjIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–2).
*/
export async function getEjChapter(
chapterNum: number,
): Promise<EjChapter | 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 null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "1", "2" → chapter
*/
export async function getEjContent(
slug: string,
): Promise<EjChapter | EjIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getEjIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
return getEjChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → chapters 1–2
*/
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getEjMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: EjSidebarItem[] = [];
// Intro segments (from DB, in order)
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–2 (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;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
*
* Reads from MongoDB via Prisma.
* EK is a simple prose book with 1 intro segment (front-pages) and 6 chapters.
*
* 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 = "ek";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EkMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: EkChapterEntry[];
};
export type EkChapterEntry = {
number: number;
title: string;
};
export type EkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type EkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type EkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "6" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getEkMeta(): Promise<EkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as EkMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getEkIntroSegment(
slug: string,
): Promise<EkIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–6).
*/
export async function getEkChapter(
chapterNum: number,
): Promise<EkChapter | 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 null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "6" → chapter
*/
export async function getEkContent(
slug: string,
): Promise<EkChapter | EkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getEkIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
return getEkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–6
*/
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getEkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: EkSidebarItem[] = [];
// Intro segments (from DB, in order)
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–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;
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Data access layer for The Kṛṣṇa Consciousness Handbook (HKH).
*
* Reads from MongoDB via Prisma.
* HKH is a prose book with 2 intro segments (front-pages, introduction)
* and 19 chapters.
*
* 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 = "hkh";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type HkhMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: HkhChapterEntry[];
};
export type HkhChapterEntry = {
number: number;
title: string;
};
export type HkhIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type HkhChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type HkhSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-introduction", "1", "2", … "7" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getHkhMeta(): Promise<HkhMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as HkhMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getHkhIntroSegments(): Promise<HkhIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getHkhIntroSegment(
slug: string,
): Promise<HkhIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–19).
*/
export async function getHkhChapter(
chapterNum: number,
): Promise<HkhChapter | null> {
if (chapterNum < 1 || chapterNum > 19 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "1" … "7" → chapter
*/
export async function getHkhContent(
slug: string,
): Promise<HkhChapter | HkhIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getHkhIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 19) {
return getHkhChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–19
*/
export async function getHkhSidebarItems(): Promise<HkhSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getHkhMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: HkhSidebarItem[] = [];
// Intro segments (from DB, in order)
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–19 (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;
}
+345
View File
@@ -0,0 +1,345 @@
/**
* Data access layer for Śrī Īśopaniṣad (ISO).
*
* Reads from MongoDB via Prisma.
* ISO is unique — it has BOTH verses (in the `verses` collection)
* AND prose intro segments (in the `prose_chapters` collection).
*
* Verses were seeded with:
* - book: "iso", division: null
* - chapter = mantra number (0 for Invocation, 1-18 for mantras)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "iso", volume: 0, type: "intro-segment"
* - slugs like "intro-front-pages", "intro-introduction", "intro-about-the-author"
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BOOK = "iso";
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Mantra title lookup
// ---------------------------------------------------------------------------
const MANTRA_TITLES: Record<number, string> = {
0: "Invocation",
1: "Mantra One",
2: "Mantra Two",
3: "Mantra Three",
4: "Mantra Four",
5: "Mantra Five",
6: "Mantra Six",
7: "Mantra Seven",
8: "Mantra Eight",
9: "Mantra Nine",
10: "Mantra Ten",
11: "Mantra Eleven",
12: "Mantra Twelve",
13: "Mantra Thirteen",
14: "Mantra Fourteen",
15: "Mantra Fifteen",
16: "Mantra Sixteen",
17: "Mantra Seventeen",
18: "Mantra Eighteen",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type IsoMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalMantras: number;
mantras: IsoMantraEntry[];
};
export type IsoMantraEntry = {
number: number;
title: string;
slug: string;
};
export type IsoIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type IsoVerse = {
book: string;
book_title: string;
mantra: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type IsoSidebarItem = {
/** URL-safe slug: "intro-front-pages", "invocation", "1", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Mantra number (0 for invocation), or null for non-verse items */
mantra: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/nod.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);
}
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, mantras list, etc.)
*/
export async function getIsoMeta(): Promise<IsoMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as IsoMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getIsoIntroSegments(): Promise<IsoIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getIsoIntroSegment(
slug: string,
): Promise<IsoIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Verses (from verses collection)
// ---------------------------------------------------------------------------
/**
* A single verse/mantra by slug.
*
* - "invocation" → chapter=0, verse=1
* - "1"-"18" → chapter=1–18, verse=1
*/
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
let mantraNum: number;
if (slug === "invocation") {
mantraNum = 0;
} else {
const num = Number(slug);
if (!Number.isInteger(num) || num < 1 || num > 18) return null;
mantraNum = num;
}
const doc = await prisma.verse.findFirst({
where: { book: BOOK, division: null, chapter: mantraNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
mantra: doc.chapter, // chapter field stores the mantra number
title: MANTRA_TITLES[doc.chapter] ?? `Mantra ${doc.chapter}`,
reference: doc.reference,
devanagari: doc.devanagari,
transliteration: doc.transliteration,
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
translation: sanitizeHtml(doc.translation),
purport: normalizePurport(doc.purport),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "invocation" → invocation verse
* "1" … "18" → mantra verse
*/
export async function getIsoContent(
slug: string,
): Promise<IsoVerse | IsoIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getIsoIntroSegment(slug);
}
if (slug === "invocation") {
return getIsoVerse("invocation");
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getIsoVerse(String(num));
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → invocation → mantras 1-18 → about-the-author
*/
export async function getIsoSidebarItems(): Promise<IsoSidebarItem[]> {
const meta = await getIsoMeta();
if (!meta) return [];
const items: IsoSidebarItem[] = [];
// 1. Front pages intro segment
items.push({
slug: "intro-front-pages",
label: "Back Cover & Front Pages",
type: "intro-segment",
mantra: null,
});
// 2. Introduction (Teachings of the Vedas)
items.push({
slug: "intro-introduction",
label: "Introduction (Teachings of the Vedas)",
type: "intro-segment",
mantra: null,
});
// 3. Invocation + Mantras 1-18 (from meta.mantras)
for (const m of meta.mantras) {
if (m.number === 0) {
items.push({
slug: "invocation",
label: "Invocation",
type: "verse",
mantra: 0,
});
} else {
items.push({
slug: String(m.number),
label: `${m.number}. ${m.title}`,
type: "verse",
mantra: m.number,
});
}
}
// 4. About the Author intro segment (at the end)
items.push({
slug: "intro-about-the-author",
label: "About the Author",
type: "intro-segment",
mantra: null,
});
return items;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
*
* Reads from MongoDB via Prisma.
* MG is a simple prose book with 1 intro segment (front-pages) and 7 chapters.
*
* 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 = "mg";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MgMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: MgChapterEntry[];
};
export type MgChapterEntry = {
number: number;
title: string;
};
export type MgIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type MgChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type MgSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "7" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getMgMeta(): Promise<MgMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as MgMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getMgIntroSegment(
slug: string,
): Promise<MgIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–7).
*/
export async function getMgChapter(
chapterNum: number,
): Promise<MgChapter | null> {
if (chapterNum < 1 || chapterNum > 7 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "7" → chapter
*/
export async function getMgContent(
slug: string,
): Promise<MgChapter | MgIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getMgIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
return getMgChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–7
*/
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getMgMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: MgSidebarItem[] = [];
// Intro segments (from DB, in order)
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–7 (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;
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Data access layer for The Nectar of Devotion (NOD).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* NoD is a prose book (like KB) but has no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* 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
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "nod";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NodMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: NodPart[];
chapters: NodChapterEntry[];
};
export type NodPart = {
number: number;
title: string;
chapters: number[];
};
export type NodChapterEntry = {
number: number;
title: string;
};
export type NodIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type NodChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type NodSidebarItem = {
/** URL-safe slug: "intro-front-flap", "1", "51", "concluding-words" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" | "conclusion" */
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, parts, chapter list, etc.)
*/
export async function getNodMeta(): Promise<NodMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as NodMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getNodIntroSegments(): Promise<NodIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
*/
export async function getNodIntroSegment(
slug: string,
): Promise<NodIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–51).
*/
export async function getNodChapter(
chapterNum: number,
): Promise<NodChapter | null> {
if (chapterNum < 1 || chapterNum > 51 || !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),
};
}
/**
* The concluding words section.
*/
export async function getNodConcludingWords(): Promise<NodChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: "concluding-words",
},
},
});
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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-flap" → intro segment
* "intro-preface" → intro segment
* "intro-introduction" → intro segment
* "1" … "51" → chapter
* "concluding-words" → concluding section
*/
export async function getNodContent(
slug: string,
): Promise<NodChapter | NodIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getNodIntroSegment(slug);
}
if (slug === "concluding-words") {
return getNodConcludingWords();
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 51) {
return getNodChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: intro segments → chapters 1–51 → concluding words
*/
export async function getNodSidebarItems(): Promise<NodSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getNodMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: NodSidebarItem[] = [];
// Intro segments (from DB, in order)
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–51 (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,
});
}
// Concluding words
const cw = allDocs.find((d) => d.type === "conclusion");
if (cw) {
items.push({
slug: cw.slug,
label: cw.title,
type: "conclusion",
chapter: null,
});
}
return items;
}
+322
View File
@@ -0,0 +1,322 @@
/**
* Data access layer for The Nectar of Instruction (NoI).
*
* Reads from MongoDB via Prisma.
* NoI has 11 texts (verses) plus intro segments (front-pages, preface, appendixes).
*
* Verses were seeded with:
* - book: "noi", division: null
* - chapter = text number (1-11)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "noi", volume: 0, type: "intro-segment"
* - slugs like "intro-front-pages", "intro-preface", "intro-appendixes"
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BOOK = "noi";
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Text title lookup
// ---------------------------------------------------------------------------
const TEXT_TITLES: Record<number, string> = {
1: "Text One",
2: "Text Two",
3: "Text Three",
4: "Text Four",
5: "Text Five",
6: "Text Six",
7: "Text Seven",
8: "Text Eight",
9: "Text Nine",
10: "Text Ten",
11: "Text Eleven",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NoiMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: NoiTextEntry[];
};
export type NoiTextEntry = {
number: number;
title: string;
slug: string;
};
export type NoiIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type NoiVerse = {
book: string;
book_title: string;
text: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type NoiSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Text number (1-11), or null for non-verse items */
text: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/iso.ts, lib/nod.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);
}
/** Normalize purport via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, texts list, etc.)
*/
export async function getNoiMeta(): Promise<NoiMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as NoiMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getNoiIntroSegments(): Promise<NoiIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getNoiIntroSegment(
slug: string,
): Promise<NoiIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Verses (texts 1-11, from verses collection)
// ---------------------------------------------------------------------------
/**
* A single verse/text by slug.
*
* - "1"-"11" → chapter=1–11, verse=1
*/
export async function getNoiVerse(slug: string): Promise<NoiVerse | null> {
const textNum = Number(slug);
if (!Number.isInteger(textNum) || textNum < 1 || textNum > 11) return null;
const doc = await prisma.verse.findFirst({
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
text: doc.chapter, // chapter field stores the text number
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
reference: doc.reference,
devanagari: doc.devanagari,
transliteration: doc.transliteration,
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
translation: sanitizeHtml(doc.translation),
purport: normalizePurport(doc.purport),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "intro-appendixes" → intro segment
* "1" … "11" → verse/text
*/
export async function getNoiContent(
slug: string,
): Promise<NoiVerse | NoiIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getNoiIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getNoiVerse(String(num));
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → texts 1-11 → appendixes
*/
export async function getNoiSidebarItems(): Promise<NoiSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getNoiMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: NoiSidebarItem[] = [];
// 1. Intro segments that come before the texts (front-pages, preface — by order)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && doc.slug !== "intro-appendixes") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
text: null,
});
}
}
// 2. Texts 1-11 (from meta.texts)
for (const t of meta.texts) {
items.push({
slug: String(t.number),
label: `${t.number}. ${t.title}`,
type: "verse",
text: t.number,
});
}
// 3. Appendixes intro segment (at the end)
const appendixes = allDocs.find((d) => d.slug === "intro-appendixes");
if (appendixes) {
items.push({
slug: appendixes.slug,
label: appendixes.title,
type: "intro-segment",
text: null,
});
}
return items;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for On Chanting the Hare Kṛṣṇa Mantra (OCHKM).
*
* Reads from MongoDB via Prisma.
* OCHKM is a prose book with 1 intro segment (front-pages) and 4 chapters.
*
* 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 = "ochkm";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OchkmMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: OchkmChapterEntry[];
};
export type OchkmChapterEntry = {
number: number;
title: string;
};
export type OchkmIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type OchkmChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type OchkmSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "4" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getOchkmMeta(): Promise<OchkmMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as OchkmMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getOchkmIntroSegment(
slug: string,
): Promise<OchkmIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getOchkmChapter(
chapterNum: number,
): Promise<OchkmChapter | 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 null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "4" → chapter
*/
export async function getOchkmContent(
slug: string,
): Promise<OchkmChapter | OchkmIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getOchkmIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getOchkmChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–4
*/
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getOchkmMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: OchkmSidebarItem[] = [];
// Intro segments (from DB, in order)
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–4 (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;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for On the Way to Kṛṣṇa (OWK).
*
* Reads from MongoDB via Prisma.
* OWK is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
*
* 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 = "owk";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OwkMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: OwkChapterEntry[];
};
export type OwkChapterEntry = {
number: number;
title: string;
};
export type OwkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type OwkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type OwkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getOwkMeta(): Promise<OwkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as OwkMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getOwkIntroSegment(
slug: string,
): Promise<OwkIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getOwkChapter(
chapterNum: number,
): Promise<OwkChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getOwkContent(
slug: string,
): Promise<OwkChapter | OwkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getOwkIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getOwkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getOwkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: OwkSidebarItem[] = [];
// Intro segments (from DB, in order)
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–5 (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;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for The Perfection of Yoga (POY).
*
* Reads from MongoDB via Prisma.
* POY is a prose book with 1 intro segment (front-pages) and 11 chapters.
*
* 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 = "poy";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PoyMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: PoyChapterEntry[];
};
export type PoyChapterEntry = {
number: number;
title: string;
};
export type PoyIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type PoyChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PoySidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "11" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPoyMeta(): Promise<PoyMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as PoyMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getPoyIntroSegment(
slug: string,
): Promise<PoyIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–11).
*/
export async function getPoyChapter(
chapterNum: number,
): Promise<PoyChapter | null> {
if (chapterNum < 1 || chapterNum > 11 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "11" → chapter
*/
export async function getPoyContent(
slug: string,
): Promise<PoyChapter | PoyIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getPoyIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getPoyChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–11
*/
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPoyMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: PoySidebarItem[] = [];
// Intro segments (from DB, in order)
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–11 (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;
}
+336
View File
@@ -0,0 +1,336 @@
/**
* Data access layer for Perfect Questions Perfect Answers (PQPA).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* PQPA is a prose/dialogue book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* 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
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "pqpa";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PqpaMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: PqpaChapterEntry[];
};
export type PqpaChapterEntry = {
number: number;
title: string;
};
export type PqpaIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type PqpaChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PqpaSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "9", "concluding-words" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" | "conclusion" */
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPqpaMeta(): Promise<PqpaMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as PqpaMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getPqpaIntroSegments(): Promise<PqpaIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getPqpaIntroSegment(
slug: string,
): Promise<PqpaIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–9).
*/
export async function getPqpaChapter(
chapterNum: number,
): Promise<PqpaChapter | null> {
if (chapterNum < 1 || chapterNum > 9 || !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),
};
}
/**
* The concluding words section.
*/
export async function getPqpaConcludingWords(): Promise<PqpaChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: "concluding-words",
},
},
});
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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "9" → chapter
* "concluding-words" → concluding section
*/
export async function getPqpaContent(
slug: string,
): Promise<PqpaChapter | PqpaIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getPqpaIntroSegment(slug);
}
if (slug === "concluding-words") {
return getPqpaConcludingWords();
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 9) {
return getPqpaChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–9 → concluding-words → about-the-author
*/
export async function getPqpaSidebarItems(): Promise<PqpaSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPqpaMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
if (!meta) return [];
const items: PqpaSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–9 (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,
});
}
// Concluding words
const cw = allDocs.find((d) => d.type === "conclusion");
if (cw) {
items.push({
slug: cw.slug,
label: cw.title,
type: "conclusion",
chapter: null,
});
}
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for Rāja-vidyā: The King of Knowledge (RV).
*
* Reads from MongoDB via Prisma.
* RV is a simple prose book with 1 intro segment (front-pages) and 8 chapters.
*
* 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 = "rv";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type RvMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: RvChapterEntry[];
};
export type RvChapterEntry = {
number: number;
title: string;
};
export type RvIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type RvChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type RvSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "8" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getRvMeta(): Promise<RvMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as RvMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getRvIntroSegments(): Promise<RvIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getRvIntroSegment(
slug: string,
): Promise<RvIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–8).
*/
export async function getRvChapter(
chapterNum: number,
): Promise<RvChapter | 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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "8" → chapter
*/
export async function getRvContent(
slug: string,
): Promise<RvChapter | RvIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getRvIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
return getRvChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–8
*/
export async function getRvSidebarItems(): Promise<RvSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getRvMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: RvSidebarItem[] = [];
// Intro segments (from DB, in order)
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–8 (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;
}
+336
View File
@@ -0,0 +1,336 @@
/**
* Data access layer for The Science of Self-Realization (SSR).
*
* Reads from MongoDB via Prisma.
* SSR is a large prose book with 6 intro segments (front-pages, dedication,
* foreword, introduction, appreciations, about-the-author) and 8 parent
* chapters, each containing multiple sub-chapters (e.g., "1-1", "1-2", "2-1").
*
* 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 = "ssr";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SsrMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: SsrParentChapter[];
};
export type SsrParentChapter = {
number: number;
title: string;
subChapters: SsrSubChapterEntry[];
};
export type SsrSubChapterEntry = {
slug: string; // "1-1", "1-2", etc.
title: string;
};
export type SsrIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type SsrChapter = {
book: string;
chapter: string; // "1-1", "2-1", etc.
parentChapter: number;
subChapter: number;
title: string;
parentTitle: string;
type: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type SsrSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "parent" | "sub-chapter" */
type: string;
/** Parent chapter number, or null for intro segments */
parentChapter: 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;
}
/** 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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
*/
export async function getSsrMeta(): Promise<SsrMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as SsrMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getSsrIntroSegments(): Promise<SsrIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getSsrIntroSegment(
slug: string,
): Promise<SsrIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters (sub-chapters like "1-1", "2-3", etc.)
// ---------------------------------------------------------------------------
/**
* A sub-chapter by its slug (e.g. "1-1", "2-3").
*/
export async function getSsrChapter(slug: string): Promise<SsrChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug,
},
},
});
if (!doc) return null;
// Parse parentChapter and subChapter from slug "X-Y"
const parts = slug.split("-");
const parentChapter = parseInt(parts[0], 10);
const subChapter = parseInt(parts[1], 10);
// Derive parentTitle from meta if available
const meta = await getSsrMeta();
const parentEntry = meta?.chapters.find((ch) => ch.number === parentChapter);
const parentTitle = parentEntry?.title ?? "";
return {
book: doc.book,
chapter: slug,
parentChapter,
subChapter,
title: doc.title,
parentTitle,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-dedication" → intro segment
* "intro-about-the-author" → intro segment
* "1-1", "2-3", etc. → sub-chapter
*/
export async function getSsrContent(
slug: string,
): Promise<SsrChapter | SsrIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getSsrIntroSegment(slug);
}
// Sub-chapter slug like "1-1", "2-3"
return getSsrChapter(slug);
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list with hierarchical structure.
*
* Order: front-pages → dedication → foreword → introduction → appreciations
* → parent-1 (label) → 1-1, 1-2, ... → parent-2 (label) → 2-1, 2-2, ...
* → about-the-author
*
* "parent" type items are non-clickable headings. Only "intro-segment" and
* "sub-chapter" items are navigable pages.
*/
export async function getSsrSidebarItems(): Promise<SsrSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getSsrMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
if (!meta) return [];
const items: SsrSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && doc.order < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
parentChapter: null,
});
}
}
// Chapters — grouped by parent chapter with sub-chapters
for (const ch of meta.chapters) {
// Parent heading (non-clickable label)
items.push({
slug: `parent-${ch.number}`,
label: `${ch.number}. ${ch.title}`,
type: "parent",
parentChapter: ch.number,
});
// Sub-chapters under this parent
for (const sub of ch.subChapters) {
items.push({
slug: sub.slug,
label: sub.title,
type: "sub-chapter",
parentChapter: ch.number,
});
}
}
// Intro segments that come AFTER chapters (order >= 99, e.g., about-the-author)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && doc.order >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
parentChapter: null,
});
}
}
return items;
}
/**
* Get the flat list of navigable sidebar items (excludes "parent" headings).
* Used for prev/next navigation.
*/
export function getNavigableItems(items: SsrSidebarItem[]): SsrSidebarItem[] {
return items.filter((item) => item.type !== "parent");
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Data access layer for Two Essays (TE).
*
* Reads from MongoDB via Prisma.
* TE is a collection of 2 standalone essays using the `essays` collection
* (not `prose_chapters`). Essays are grouped by `group: "two-essays"`.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - essays (via Prisma Essay model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const GROUP = "two-essays";
const BOOK = "te";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TeMeta = {
group: string;
title: string;
author: string;
year: number;
description: string;
essays: TeEssayEntry[];
};
export type TeEssayEntry = {
slug: string;
title: string;
order: number;
};
export type TeEssay = {
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
order: number;
paragraphs: string[];
};
export type TeSidebarItem = {
slug: string;
label: string;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/ej.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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, essay list, etc.)
*/
export async function getTeMeta(): Promise<TeMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as TeMeta;
}
// ---------------------------------------------------------------------------
// Essays
// ---------------------------------------------------------------------------
/**
* All essays in the "two-essays" group, ordered by `order`.
*/
export async function getTeEssays(): Promise<TeEssay[]> {
const results = await prisma.essay.findMany({
where: { group: GROUP },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
author: doc.author,
year: doc.year,
group: doc.group,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single essay by slug.
*/
export async function getTeEssay(slug: string): Promise<TeEssay | null> {
const doc = await prisma.essay.findUnique({ where: { slug } });
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
author: doc.author,
year: doc.year,
group: doc.group,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the sidebar navigation list from meta.
*/
export async function getTeSidebarItems(): Promise<TeSidebarItem[]> {
const meta = await getTeMeta();
if (!meta) return [];
return meta.essays.map((e) => ({
slug: e.slug,
label: e.title,
}));
}
+292
View File
@@ -0,0 +1,292 @@
/**
* Data access layer for Teachings of Lord Caitanya (TLC).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* TLC is a prose book (like KB) but has no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* 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
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "tlc";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TlcMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: TlcPart[];
chapters: TlcChapterEntry[];
};
export type TlcPart = {
number: number;
title: string;
chapters: number[];
};
export type TlcChapterEntry = {
number: number;
title: string;
};
export type TlcIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TlcChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TlcSidebarItem = {
/** URL-safe slug: "intro-front-flap", "1", "32" */
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, parts, chapter list, etc.)
*/
export async function getTlcMeta(): Promise<TlcMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as TlcMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTlcIntroSegments(): Promise<TlcIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
*/
export async function getTlcIntroSegment(
slug: string,
): Promise<TlcIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–32).
*/
export async function getTlcChapter(
chapterNum: number,
): Promise<TlcChapter | null> {
if (chapterNum < 1 || chapterNum > 32 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-flap" → intro segment
* "intro-preface" → intro segment
* "intro-introduction" → intro segment
* "1" … "32" → chapter
*/
export async function getTlcContent(
slug: string,
): Promise<TlcChapter | TlcIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTlcIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 32) {
return getTlcChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: intro segments → chapters 1–32
* (TLC has no concluding-words section.)
*/
export async function getTlcSidebarItems(): Promise<TlcSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTlcMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: TlcSidebarItem[] = [];
// Intro segments (from DB, in order)
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–32 (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;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Data access layer for Transcendental Teachings of Prahlāda Mahārāja (TTP).
*
* Reads from MongoDB via Prisma.
* TTP is a prose book with 1 intro segment (front-pages) and 5 chapters.
*
* 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 = "ttp";
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TtpMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: TtpChapterEntry[];
};
export type TtpChapterEntry = {
number: number;
title: string;
};
export type TtpIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TtpChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TtpSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
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
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getTtpMeta(): Promise<TtpMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as TtpMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTtpIntroSegments(): Promise<TtpIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getTtpIntroSegment(
slug: string,
): Promise<TtpIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getTtpChapter(
chapterNum: number,
): Promise<TtpChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !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
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getTtpContent(
slug: string,
): Promise<TtpChapter | TtpIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTtpIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getTtpChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getTtpSidebarItems(): Promise<TtpSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTtpMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: TtpSidebarItem[] = [];
// Intro segments (from DB, in order)
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–5 (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;
}
+25
View File
@@ -93,6 +93,31 @@ model ProseChapter {
@@map("prose_chapters")
}
// ---------------------------------------------------------------------------
// Essay — standalone essays (not part of any book's chapter structure)
//
// slug: URL-friendly identifier (e.g. "krishna-reservoir-of-pleasure")
// title: essay title
// author: author name
// year: publication year
// group: optional grouping key (e.g. "two-essays" for essays published together)
// order: sort order within a group
// paragraphs: array of HTML paragraph strings
// ---------------------------------------------------------------------------
model Essay {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
title String
author String
year Int?
group String?
order Int
paragraphs String[]
@@index([group])
@@map("essays")
}
// ---------------------------------------------------------------------------
// GlossaryEntry — individual glossary term + definition
// ---------------------------------------------------------------------------
+2 -3
View File
@@ -12027,8 +12027,7 @@ Boston, Mass. 02134</p>
<p class="p6">O my Lord! When will my eyes be decorated with tears of love, flowing constantly while I chant Your holy name? When will my words be choked up when uttering the holy name? And when will all the holes of hair on my body have eruptions by the recitation of Your name?</p>
<p class="p6">O Govinda! Feeling Your separation I am considering a moment twelve years or more, and tears are flowing from my eyes like torrents of rain. I am feeling all vacant in the world in the absence of You.</p>
<p class="p6">I do not know anyone except Kåñëa as my Lord, and He will always remain as such, even if He handles me roughly by His embrace or makes me broken-hearted by not being present before me. He is completely free to do anything and everything, but He is always my worshipable Lord, unconditionally.</p>
<p class="p12"><b>The <br>
Krsna Consciousness Handbook (1971)</b></p>
<p class="p12"><b>The Krsna Consciousness Handbook (1971)</b></p>
<p class="p19">FOR THE YEAR 484, <br>
CAITANYA ERA <br>
(March 24, 1970 March 12, 1971)</p>
@@ -13322,7 +13321,7 @@ ADDRESS _________________________</p>
Former Prime Minister of India</p>
<p class="p12"><b>The Science of Self-Realization (1977)</b></p>
<p class="p128">Readers interested in the subject matter of this book are invited by the International Society for Krishna Consciousness to correspond with its Secretary.</p>
<p class="p25">I<b>nternational Society for Krishna Consciousness</b></p>
<p class="p25">International Society for Krishna Consciousness</p>
<p class="p25"><b>3764 Watseka Avenue<br>
Los Angeles, California 90034</b></p>
<p class="p128"><span class="s4"><br>
+665
View File
@@ -0,0 +1,665 @@
#!/usr/bin/env python3
"""Extract Beyond Birth and Death (1974) from other.html.
The book has 1 intro segment (front-pages) and 5 chapters.
Output:
lib/jsons/books/bbd/intro.json — Book metadata
lib/jsons/books/bbd/intro/*.json — Intro segments
lib/jsons/books/bbd/chapters/*.json — Chapters
Usage:
python3 scripts/bbd_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for BBD
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53"])
# Bold quote paragraphs — include as body
BOLD_QUOTE_CLASSES = frozenset(["p56", "p30"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Table of contents
TOC_CLASS = "p80"
# END marker class
END_MARKER_CLASS = "p81"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p70"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_bbd_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the BBD content.
Start: <p class="p12"> containing "Beyond Birth and Death" and "1974"
End: <p class="p81"><b>END</b></p> or next <p class="p12"> after start
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Beyond Birth and Death" in text and "1974" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after BBD start = end of BBD
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Beyond Birth and Death (1974)' header in other.html!"
)
sys.exit(1)
# Also check for END marker (p81 with text "END") as end boundary
for i in range(start_idx + 1, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == END_MARKER_CLASS:
text = p.get_text().strip()
if text.upper() == "END":
end_idx = i
break
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all BBD chapter bookmark tags (p13) in the BBD range.
Returns list of (paragraph_index, chapter_number, 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)
m = re.match(r"BBD\s+(\d+):\s+(.+)", text.strip())
if m:
ch_num = int(m.group(1))
ch_title = m.group(2).strip()
bookmarks.append((i, ch_num, ch_title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the BBD content.
Groups consecutive p53 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip END marker (p81)
if cls == END_MARKER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Table of contents (p80) — include in front-pages
if cls == TOC_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Bold quote paragraphs (p56, p30) — include as body
if cls in BOLD_QUOTE_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_bbd(soup: BeautifulSoup) -> dict:
"""Extract the entire Beyond Birth and Death (1974) 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 BBD boundaries
bbd_start, bbd_end = find_bbd_boundaries(all_paragraphs)
print(f" BBD content: paragraphs {bbd_start} to {bbd_end}")
print(f" Total paragraphs in range: {bbd_end - bbd_start}")
# Step 2: Find all chapter bookmarks
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, bbd_start, bbd_end)
print(f" Found {len(chapter_bookmarks)} BBD chapter bookmarks")
for pos, ch_num, ch_title in chapter_bookmarks:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (bbd_start) to first chapter bookmark (BBD 1)
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = bbd_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, bbd_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "bbd",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or bbd_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = bbd_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "bbd",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "bbd")
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 Beyond Birth and Death (1974)...\n")
result = extract_bbd(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "bbd",
"title": "Beyond Birth and Death",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1974,
"description": "An introduction to the science of the self, transcending the cycle of birth and death through Kṛṣṇa consciousness.",
"totalChapters": 5,
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 5 chapters present
expected_chapters = set(range(1, 6))
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 5 chapters present")
# Check all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+750
View File
@@ -0,0 +1,750 @@
#!/usr/bin/env python3
"""Extract Easy Journey to Other Planets (1970 Edition) from other.html.
The book has 2 intro segments (front-pages, preface) and 2 chapters.
Output:
lib/jsons/books/ej/intro.json — Book metadata
lib/jsons/books/ej/intro/*.json — Intro segments
lib/jsons/books/ej/chapters/*.json — Chapters
Usage:
python3 scripts/ej_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for EJ'70
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Section bookmark class (Preface, Glossary)
SECTION_BOOKMARK_CLASS = "p64"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p49"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p45", "p63", "p72", "p71"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p10", "p73"])
# Section heading (bold) — "Preface", "1 Anti-material Worlds", etc.
SECTION_HEADING_CLASS = "p31"
# Front-pages specific classes
BOOK_LIST_CLASS = "p68"
TITLE_PAGE_CLASS = "p3"
BOOK_TITLE_CLASS = "p69"
# Price text — skip
PRICE_CLASS = "p42"
# Glossary header — end boundary marker
GLOSSARY_HEADER_CLASS = "p19"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p70"])
# ---------------------------------------------------------------------------
# Intro segment definitions
# ---------------------------------------------------------------------------
INTRO_SEGMENTS = [
{
"slug": "front-pages",
"title": "Front Pages",
"order": 1,
"boundary_type": "start", # from book start to first section bookmark
},
{
"slug": "preface",
"title": "Preface",
"order": 2,
"bookmark_text": "EJ'70:Preface",
},
]
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_ej_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the EJ'70 content.
Start: <p class="p12"> containing "Easy Journey" and "1970"
End: next <p class="p12"> after start, or the EJ'70:Glossary bookmark
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Easy Journey" in text and "1970" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after EJ start = end of EJ
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Easy Journey to other planets (1970)' header in other.html!"
)
sys.exit(1)
# Also check for the EJ'70:Glossary bookmark as an end boundary
for i in range(start_idx + 1, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == SECTION_BOOKMARK_CLASS:
text = decode_text(p)
if "EJ'70:Glossary" in text:
end_idx = i
break
return start_idx, end_idx
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all bookmark tags (p64 and p49) in the EJ range.
Returns list of (paragraph_index, bookmark_text, bookmark_type).
bookmark_type is 'section' for p64, 'chapter' for p49.
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == SECTION_BOOKMARK_CLASS:
text = decode_text(p)
if "EJ'70:" in text:
bookmarks.append((i, text, "section"))
elif cls == CHAPTER_BOOKMARK_CLASS:
text = decode_text(p)
if "EJ'70:" in text:
bookmarks.append((i, text, "chapter"))
return bookmarks
def parse_chapter_bookmark(text: str) -> tuple:
"""Parse a chapter bookmark like "EJ'70:1 Anti-material Worlds".
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"EJ'70:(\d+)\s+(.+)", text.strip())
if m:
return int(m.group(1)), m.group(2).strip()
return None
def parse_section_bookmark(text: str) -> str:
"""Parse a section bookmark like "EJ'70:Preface" or "EJ'70:Glossary".
Returns the section name or None.
"""
m = re.match(r"EJ'70:(\w+)", text.strip())
if m:
return m.group(1).strip()
return None
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the EJ content.
Groups consecutive p10/p73 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (both p64 and p49)
if cls in (SECTION_BOOKMARK_CLASS, CHAPTER_BOOKMARK_CLASS):
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip price text (p42)
if cls == PRICE_CLASS:
continue
# Skip glossary header (p19)
if cls == GLOSSARY_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p10, p73) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Section heading (p31) — include as paragraph
if cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Book title bold (p69) — include in front-pages
if cls == BOOK_TITLE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include in front-pages
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Book list items (p68) — include in front-pages
if cls == BOOK_LIST_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p45, p63, p72, p71)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_ej(soup: BeautifulSoup) -> dict:
"""Extract the entire Easy Journey to Other Planets (1970) 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 EJ boundaries
ej_start, ej_end = find_ej_boundaries(all_paragraphs)
print(f" EJ'70 content: paragraphs {ej_start} to {ej_end}")
print(f" Total paragraphs in range: {ej_end - ej_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, ej_start, ej_end)
print(f" Found {len(bookmarks)} EJ'70 bookmarks")
for pos, text, btype in bookmarks:
print(f" [{btype}] paragraph {pos}: {text}")
# Step 3: Categorize bookmarks into section vs chapter
section_bookmarks = [] # (para_idx, bookmark_text)
chapter_bookmarks = [] # (para_idx, chapter_num, title)
for pos, text, btype in bookmarks:
ch = parse_chapter_bookmark(text)
if ch:
chapter_bookmarks.append((pos, ch[0], ch[1]))
else:
section_name = parse_section_bookmark(text)
if section_name and section_name != "Glossary":
section_bookmarks.append((pos, text))
print(f" Section bookmarks: {len(section_bookmarks)}")
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
# Step 4: Collect all boundary positions for range detection
# These are all the positions where a new section/chapter starts
all_boundary_positions = sorted(
[pos for pos, _ in section_bookmarks] + [pos for pos, _, _ in chapter_bookmarks]
)
# Step 5: Extract intro segments
intro_segments = []
# --- Front Pages ---
# From book start (ej_start) to first bookmark (Preface)
front_pages_end = all_boundary_positions[0] if all_boundary_positions else ej_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, ej_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "ej",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# --- Preface ---
# From EJ'70:Preface bookmark to first chapter bookmark
preface_pos = None
for pos, text in section_bookmarks:
if "Preface" in text:
preface_pos = pos
break
if preface_pos is not None:
# Content starts after the bookmark
preface_start = preface_pos + 1
# Content ends at the first chapter bookmark
preface_end = ej_end
for s in all_boundary_positions:
if s > preface_pos:
preface_end = s
break
preface_paragraphs = extract_paragraphs(
all_paragraphs, preface_start, preface_end
)
intro_segments.append(
{
"slug": "preface",
"title": "Preface",
"type": "intro-segment",
"book": "ej",
"order": 2,
"paragraphs": preface_paragraphs,
}
)
else:
print(" WARNING: Could not find Preface bookmark!")
# Step 6: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or ej_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = ej_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "ej",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 7: 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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ej")
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 Easy Journey to Other Planets (1970 Edition)...\n")
result = extract_ej(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "ej",
"title": "Easy Journey to Other Planets",
"subtitle": "By Practice of Supreme Yoga",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1970,
"description": "How one can transfer oneself to other planets by the process of bhakti-yoga.",
"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 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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
print(f" [{i}] {preview}...")
if len(intro_segments) > 1:
print(f"\n--- Sample: Preface ---")
preface = intro_segments[1]
for i, p in enumerate(preface["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- 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 all 2 intro segments present
expected_intros = {"front-pages", "preface"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 2 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "Gītā" in all_text or "gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
# Look for patterns that are very likely ScaGoudy artifacts
# e.g., "Kåñëa" instead of "Kṛṣṇa"
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+655
View File
@@ -0,0 +1,655 @@
#!/usr/bin/env python3
"""Extract Elevation to Kṛṣṇa Consciousness (1973) from other.html.
The book has 1 intro segment (front-pages) and 6 chapters.
Output:
lib/jsons/books/ek/intro.json — Book metadata
lib/jsons/books/ek/intro/*.json — Intro segments
lib/jsons/books/ek/chapters/*.json — Chapters
Usage:
python3 scripts/ek_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for EK
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Source reference
SOURCE_REF_CLASS = "p74"
# Glossary header — end boundary marker
GLOSSARY_HEADER_CLASS = "p19"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p70"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_ek_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the EK content.
Start: <p class="p12"> containing "Elevation to" and "1973"
End: next <p class="p12"> after start, or <p class="p19">GLOSSARY</p>
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Elevation" in text and "1973" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after EK start = end of EK
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Elevation to Kṛṣṇa Consciousness (1973)' header in other.html!"
)
sys.exit(1)
# Also check for GLOSSARY as end boundary (p19 with text "GLOSSARY")
for i in range(start_idx + 1, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == GLOSSARY_HEADER_CLASS:
text = p.get_text().strip()
if text.upper().startswith("GLOSSARY"):
end_idx = i
break
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all EK chapter bookmark tags (p13) in the EK range.
Returns list of (paragraph_index, chapter_number, 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)
m = re.match(r"EK\s+(\d+):\s+(.+)", text.strip())
if m:
ch_num = int(m.group(1))
ch_title = m.group(2).strip()
bookmarks.append((i, ch_num, ch_title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the EK content.
Groups consecutive p53 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip glossary header (p19)
if cls == GLOSSARY_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Source reference (p74) — include as paragraph
if cls == SOURCE_REF_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_ek(soup: BeautifulSoup) -> dict:
"""Extract the entire Elevation to Kṛṣṇa Consciousness (1973) 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 EK boundaries
ek_start, ek_end = find_ek_boundaries(all_paragraphs)
print(f" EK content: paragraphs {ek_start} to {ek_end}")
print(f" Total paragraphs in range: {ek_end - ek_start}")
# Step 2: Find all chapter bookmarks
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, ek_start, ek_end)
print(f" Found {len(chapter_bookmarks)} EK chapter bookmarks")
for pos, ch_num, ch_title in chapter_bookmarks:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (ek_start) to first chapter bookmark (EK 1)
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = ek_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, ek_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "ek",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or ek_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = ek_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "ek",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ek")
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 Elevation to Kṛṣṇa Consciousness (1973)...\n")
result = extract_ek(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "ek",
"title": "Elevation to Kṛṣṇa Consciousness",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1973,
"description": "How to elevate oneself from material consciousness to the spiritual platform of Kṛṣṇa consciousness.",
"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 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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- 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 all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+932
View File
@@ -0,0 +1,932 @@
#!/usr/bin/env python3
"""Extract The Kṛṣṇa Consciousness Handbook (1971) from other.html.
The book has 2 intro segments (front-pages, introduction) and 7 chapters.
Main chapter bookmarks (p13 class):
- HKH: Introduction
- HKH 1: A Brief History of ISKCON
- HKH 2: Teachings of ISKCON
- HKH 3: Brief Biography of the Spiritual Master
- HKH 4: Paramparā
- HKH 5: Ministers for Kṛṣṇa Consciousness: ...
- HKH 6: Transcendental Aspects
- HKH 7: The Qualifications and Curriculum of the Divinity Student
Sub-section bookmarks (p49 class):
- HKH 6a–6f within chapter 6
- HKH 7a–7l within chapter 7
- HKH 7m is the END boundary (book catalog, not handbook content)
Output:
lib/jsons/books/hkh/intro.json — Book metadata
lib/jsons/books/hkh/intro/front-pages.json
lib/jsons/books/hkh/intro/introduction.json
lib/jsons/books/hkh/chapters/01.json through 07.json
Usage:
python3 scripts/hkh_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for HKH
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Chapter bookmark class (main chapters)
CHAPTER_BOOKMARK_CLASS = "p13"
# Sub-section bookmark class
SUBSECTION_BOOKMARK_CLASS = "p49"
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53"])
# Classes to skip entirely
SKIP_CLASSES = frozenset(["p9", "p70"])
# Body paragraph classes — be permissive, handle everything not otherwise special
BODY_CLASSES = frozenset(
[
"p6",
"p67",
"p104",
"p101",
"p46",
"p48",
"p103",
"p105",
"p36",
"p37",
"p24",
"p44",
"p28",
"p109",
"p110",
"p111",
"p112",
"p113",
"p114",
"p115",
"p95",
"p82",
"p25",
"p118",
]
)
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_hkh_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the HKH content.
Start: <p class="p12"> containing "Krsna Consciousness Handbook" and "1971"
End: <p class="p49"> containing "HKH 7m:" (book catalog section)
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = p.get_text().strip()
# Find start
if (
cls == BOOK_HEADER_CLASS
and "Krsna Consciousness Handbook" in text
and "1971" in text
):
start_idx = i
# Find end: p49 containing "HKH 7m:"
if start_idx is not None and cls == SUBSECTION_BOOKMARK_CLASS:
if re.search(r"HKH\s+7m:", text):
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'The Krsna Consciousness Handbook (1971)' header in other.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 HKH chapter/intro bookmark tags (p13) in the HKH range.
Returns list of (paragraph_index, chapter_number_or_None, chapter_title, bookmark_type).
bookmark_type is "intro" for "HKH: Introduction" and "chapter" for numbered chapters.
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls != CHAPTER_BOOKMARK_CLASS:
continue
text = decode_text(p)
# Check for introduction bookmark
if re.match(r"HKH:\s+Introduction", text.strip()):
bookmarks.append((i, None, "Introduction", "intro"))
continue
# Check for numbered chapter bookmark (handle multi-line titles)
# Collapse newlines for matching
collapsed = re.sub(r"\s+", " ", text.strip())
m = re.match(r"HKH\s+(\d+):\s+(.+)", collapsed)
if m:
ch_num = int(m.group(1))
ch_title = m.group(2).strip()
bookmarks.append((i, ch_num, ch_title, "chapter"))
return bookmarks
def find_subsection_bookmarks(
all_paragraphs: list, start_idx: int, end_idx: int
) -> list:
"""Find all HKH sub-section bookmark tags (p49) in a range.
Returns list of (paragraph_index, subsection_code, subsection_title).
e.g. (12149, "6a", "Śikhā")
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls != SUBSECTION_BOOKMARK_CLASS:
continue
text = decode_text(p)
# Match HKH Nx: Title patterns
collapsed = re.sub(r"\s+", " ", text.strip())
m = re.match(r"HKH\s+(\d+[a-z]):\s*(.*)", collapsed)
if m:
code = m.group(1)
title = m.group(2).strip()
# Clean up title — remove surrounding <i> artifacts and leading/trailing whitespace
title = re.sub(r"</?i>", "", title).strip()
bookmarks.append((i, code, title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
subsection_bookmarks: list = None,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the HKH content.
Groups consecutive p53 verse pādas into centered italic divs.
Sub-section bookmarks (p49) are converted to bold heading paragraphs.
"""
result = []
verse_accumulator = []
if subsection_bookmarks is None:
subsection_bookmarks = []
# Build a set of subsection bookmark indices for quick lookup
subsection_indices = {b[0] for b in subsection_bookmarks}
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip chapter bookmark tags (p13) — boundaries only
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Handle sub-section bookmarks (p49) — convert to bold headings
if cls == SUBSECTION_BOOKMARK_CLASS:
flush_verse()
if i in subsection_indices:
# Find the matching subsection bookmark
for bm in subsection_bookmarks:
if bm[0] == i:
sub_title = bm[2]
if sub_title:
result.append(f"<b>{sub_title}</b>")
break
# Skip p49 tags that aren't in subsection_bookmarks (shouldn't happen in range)
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Section headings (p31, p27) — include as paragraphs
if cls in ("p31", "p27"):
html = decode_html_content(p)
if html:
result.append(html)
continue
# Misc text (p19, p21) — include
if cls in ("p19", "p21"):
html = decode_html_content(p)
if html:
result.append(html)
continue
# Contents heading (p69) — include
if cls == "p69":
html = decode_html_content(p)
if html:
result.append(html)
continue
# Contact info (p102) — include in front pages
if cls == "p102":
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs and everything else — be permissive
html = decode_html_content(p)
if html and not is_dash_line(text):
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(f'<div style="text-align:center"><i>{inner}</i></div>')
else:
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Clean chapter titles
# ---------------------------------------------------------------------------
# Map of chapter number to clean Unicode title
CLEAN_TITLES = {
1: "A Brief History of ISKCON",
2: "Teachings of ISKCON",
3: "Brief Biography of the Spiritual Master",
4: "Paramparā",
5: "Ministers for Kṛṣṇa Consciousness",
6: "Transcendental Aspects",
7: "The Qualifications and Curriculum of the Divinity Student",
}
def clean_chapter_title(ch_num: int, raw_title: str) -> str:
"""Return a clean Unicode title for the chapter."""
if ch_num in CLEAN_TITLES:
return CLEAN_TITLES[ch_num]
# Fallback: decode ScaGoudy from raw title
return decode_scagoudy(raw_title)
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_hkh(soup: BeautifulSoup) -> dict:
"""Extract the entire Kṛṣṇa Consciousness Handbook (1971) from the parsed HTML.
Chapter 6 ("Transcendental Aspects") sub-sections (Śikhā, Beads, Ārātrika,
Garlands, Deities, Diet) are promoted to their own chapters (6-11), and the
old chapter 7 becomes chapter 12. This gives 12 total chapters.
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 HKH boundaries
hkh_start, hkh_end = find_hkh_boundaries(all_paragraphs)
print(f" HKH content: paragraphs {hkh_start} to {hkh_end}")
print(f" Total paragraphs in range: {hkh_end - hkh_start}")
# Step 2: Find all chapter bookmarks
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, hkh_start, hkh_end)
print(f" Found {len(chapter_bookmarks)} HKH chapter bookmarks")
for pos, ch_num, ch_title, bm_type in chapter_bookmarks:
if bm_type == "intro":
print(f" [introduction] paragraph {pos}: {ch_title}")
else:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Find all sub-section bookmarks in the full range
all_subsections = find_subsection_bookmarks(all_paragraphs, hkh_start, hkh_end)
print(f" Found {len(all_subsections)} sub-section bookmarks")
# Step 4: Separate intro bookmark from chapter bookmarks
intro_bookmark = None
numbered_bookmarks = []
for bm in chapter_bookmarks:
if bm[3] == "intro":
intro_bookmark = bm
else:
numbered_bookmarks.append(bm)
# Step 5: Extract front pages
intro_segments = []
if intro_bookmark:
front_pages_end = intro_bookmark[0]
elif numbered_bookmarks:
front_pages_end = numbered_bookmarks[0][0]
else:
front_pages_end = hkh_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, hkh_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "hkh",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 6: Extract introduction segment
if intro_bookmark:
intro_start = intro_bookmark[0] + 1
if numbered_bookmarks:
intro_end = numbered_bookmarks[0][0]
else:
intro_end = hkh_end
intro_paragraphs = extract_paragraphs(all_paragraphs, intro_start, intro_end)
intro_segments.append(
{
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "hkh",
"order": 2,
"paragraphs": intro_paragraphs,
}
)
# Step 7: Build the effective chapter list.
#
# Chapters 1-5 stay as-is from p13 bookmarks.
# Chapter 6 ("Transcendental Aspects") is SPLIT: its p49 sub-sections
# (6a=Śikhā, 6b=Beads, 6c=Ārātrika, 6d=Garlands, 6e=Deities, 6f=Diet)
# each become their own chapter (renumbered 6-11).
# Old chapter 7 is also SPLIT: 7a+7b stay together as "The Qualifications
# and Curriculum of the Divinity Student" (chapter 12), and 7c-7l each
# become their own chapters (13-21). Total: 21 chapters.
# Identify the chapter-6 and chapter-7 bookmarks
ch6_bm = None
ch7_bm = None
for bm in numbered_bookmarks:
if bm[1] == 6:
ch6_bm = bm
elif bm[1] == 7:
ch7_bm = bm
# Find chapter 6's sub-section bookmarks (6a through 6f)
ch6_subsections = []
if ch6_bm and ch7_bm:
ch6_range_start = ch6_bm[0]
ch6_range_end = ch7_bm[0]
ch6_subsections = [
s for s in all_subsections if ch6_range_start <= s[0] < ch6_range_end
]
# Find chapter 7's sub-section bookmarks (7a through 7l)
ch7_subsections = []
if ch7_bm:
ch7_range_start = ch7_bm[0]
ch7_range_end = hkh_end
ch7_subsections = [
s for s in all_subsections if ch7_range_start <= s[0] < ch7_range_end
]
# Sub-sections to skip entirely:
# 7a = "Entrance Qualifications", 7b = "Curriculum" (administrative)
# 7h = "ISKCON Press" (organizational, not spiritual content)
CH7_SKIP_CODES = {"7a", "7b", "7h"}
ch7_split = [
s
for s in ch7_subsections
if s[1].split(":")[0].strip().replace("HKH ", "") not in CH7_SKIP_CODES
]
chapters = []
output_ch_num = 0
for idx, (ch_pos, ch_num, ch_title, _) in enumerate(numbered_bookmarks):
content_start = ch_pos + 1
if idx + 1 < len(numbered_bookmarks):
content_end = numbered_bookmarks[idx + 1][0]
else:
content_end = hkh_end
# --- Special handling for chapter 6: split into sub-section chapters ---
if ch_num == 6 and ch6_subsections:
for sub_idx, (sub_pos, sub_code, sub_title) in enumerate(ch6_subsections):
output_ch_num += 1
sub_start = sub_pos + 1
if sub_idx + 1 < len(ch6_subsections):
sub_end = ch6_subsections[sub_idx + 1][0]
else:
sub_end = content_end
paragraphs = extract_paragraphs(all_paragraphs, sub_start, sub_end)
clean_title = decode_scagoudy(sub_title).strip()
chapters.append(
{
"book": "hkh",
"chapter": output_ch_num,
"title": clean_title,
"type": "chapter",
"paragraphs": paragraphs,
}
)
continue
# --- Special handling for chapter 7: skip 7a+7b (curriculum), split 7c-7l ---
if ch_num == 7 and ch7_split:
# Skip excluded sub-sections (7a, 7b, 7h).
# Only emit remaining sub-sections as separate chapters.
for sub_idx, (sub_pos, sub_code, sub_title) in enumerate(ch7_split):
output_ch_num += 1
sub_start = sub_pos + 1
if sub_idx + 1 < len(ch7_split):
sub_end = ch7_split[sub_idx + 1][0]
else:
sub_end = content_end
paragraphs = extract_paragraphs(all_paragraphs, sub_start, sub_end)
clean_title = decode_scagoudy(sub_title).strip()
chapters.append(
{
"book": "hkh",
"chapter": output_ch_num,
"title": clean_title,
"type": "chapter",
"paragraphs": paragraphs,
}
)
continue
# --- Normal chapters (1-5) ---
output_ch_num += 1
paragraphs = extract_paragraphs(
all_paragraphs,
content_start,
content_end,
)
clean_title = clean_chapter_title(ch_num, ch_title)
chapters.append(
{
"book": "hkh",
"chapter": output_ch_num,
"title": clean_title,
"type": "chapter",
"paragraphs": paragraphs,
}
)
# Step 8: 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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "hkh")
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 The Kṛṣṇa Consciousness Handbook (1971)...\n")
result = extract_hkh(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "hkh",
"title": "The Kṛṣṇa Consciousness Handbook",
"author": "International Society for Krishna Consciousness",
"year": 1971,
"description": "The first annual handbook of ISKCON, covering the history, teachings, curriculum, and practices of Kṛṣṇa consciousness.",
"totalChapters": 7,
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
print(f" [{i}] {preview}...")
if len(intro_segments) > 1:
print(f"\n--- Sample: Introduction ---")
intro_seg = intro_segments[1]
for i, p in enumerate(intro_seg["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 7 chapters present
expected_chapters = set(range(1, 8))
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 7 chapters present")
# Check all 2 intro segments present
expected_intros = {"front-pages", "introduction"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 2 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
if "ISKCON" in all_text:
print(f" ✅ 'ISKCON' found")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Check sub-section headings in chapters 6 and 7
print(f"\n--- Sub-section Heading Check ---")
for ch in chapters:
if ch["chapter"] in (6, 7):
bold_headings = [
p
for p in ch["paragraphs"]
if p.startswith("<b>")
and p.endswith("</b>")
and len(re.sub(r"<[^>]+>", "", p)) < 80
]
print(
f" Chapter {ch['chapter']}: {len(bold_headings)} sub-section headings found"
)
for h in bold_headings[:5]:
preview = re.sub(r"<[^>]+>", "", h)
print(f" → {preview}")
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+817
View File
@@ -0,0 +1,817 @@
#!/usr/bin/env python3
"""Extract Śrī Īśopaniṣad (1969 Edition) verses from other.html into structured JSON files."""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
from indic_transliteration import sanscript
from indic_transliteration.sanscript import transliterate
# --- ScaGoudy → IAST Unicode mapping ---
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ", # long vocalic r (U+1E5D)
"û": "ṁl", # anusvara + l (ScaGoudy ligature glyph)
"ÿ": "ḷ", # vocalic l (U+1E37)
}
# --- Mantra number to title mapping ---
MANTRA_TITLES = {
0: "Invocation",
1: "Mantra One",
2: "Mantra Two",
3: "Mantra Three",
4: "Mantra Four",
5: "Mantra Five",
6: "Mantra Six",
7: "Mantra Seven",
8: "Mantra Eight",
9: "Mantra Nine",
10: "Mantra Ten",
11: "Mantra Eleven",
12: "Mantra Twelve",
13: "Mantra Thirteen",
14: "Mantra Fourteen",
15: "Mantra Fifteen",
16: "Mantra Sixteen",
17: "Mantra Seventeen",
18: "Mantra Eighteen",
}
# --- CSS class constants ---
BOOK_HEADER_CLASS = "p12"
BOOKMARK_CLASS = "p13"
VERSE_BOOKMARK_CLASS = "p49"
VERSE_HEADING_CLASS = "p50"
DEVANAGARI_CLASS = "p51" # RM Devanagari — SKIP
TEXT_LABEL_CLASS = "p52"
INVOCATION_TRANSLIT_CLASS = "p53"
PADA_TRANSLIT_CLASS = "p5"
SYNONYM_CLASSES = ("p54", "p59")
TRANSLATION_LABEL_CLASS = "p55"
TRANSLATION_TEXT_CLASS = "p56"
PURPORT_LABEL_CLASS = "p57"
PURPORT_CLASSES = ("p48", "p58", "p23", "p60", "p63", "p46", "p47", "p61", "p62")
SECTION_HEADING_CLASS = "p31"
BODY_CLASSES = ("p6", "p45")
SKIP_CLASSES = ("p9", "p15", "p19")
def decode_scagoudy(text: str) -> str:
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (artifacts from RTF conversion)
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:
"""Extract plain text from a tag and decode ScaGoudy."""
text = tag.get_text()
return decode_scagoudy(text).strip()
def normalize_devanagari_anusvara(text: str) -> str:
"""Replace explicit class nasals before same-class consonants with anusvāra.
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
"""
replacements = [
# ङ् before velars (क, ख, ग, घ)
(r"ङ्([कखगघ])", r"ं\1"),
# ञ् before palatals (च, छ, ज, झ)
(r"ञ्([चछजझ])", r"ं\1"),
]
for pattern, repl in replacements:
text = re.sub(pattern, repl, text)
return text
def join_virama_words(text: str) -> str:
"""Join Devanagari words where the previous word ends with virāma (्).
Handles both virāma+consonant and virāma+vowel cases.
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
"""
# Join virāma + space + consonant (क U+0915 through ह U+0939)
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
# Join virāma + space + independent vowel
_VOWEL_TO_MATRA = {
"अ": "", # inherent vowel — just drop virāma and the अ
"आ": "ा",
"इ": "ि",
"ई": "ी",
"उ": "ु",
"ऊ": "ू",
"ऋ": "ृ",
"ॠ": "ॄ",
"ऌ": "ॢ",
"ॡ": "ॣ",
"ए": "े",
"ऐ": "ै",
"ओ": "ो",
"औ": "ौ",
}
def _virama_vowel_replace(m: re.Match[str]) -> str:
vowel = m.group(1)
return _VOWEL_TO_MATRA.get(vowel, vowel)
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
return text
def iast_to_devanagari(iast_text: str) -> str:
"""Convert IAST transliteration to Devanagari script."""
lines = iast_text.split("\n")
result_lines = []
for line in lines:
# Remove hyphens between words for Devanagari
clean = line.replace("-", "")
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
clean = clean.replace("ṁ", "ṃ")
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
deva = normalize_devanagari_anusvara(deva)
deva = join_virama_words(deva)
result_lines.append(deva)
return "\n".join(result_lines)
def parse_synonyms(text: str) -> list:
"""Parse synonym text into structured array.
Input format: "word—meaning; word—meaning; word—meaning"
"""
result = []
entries = text.split(";")
for entry in entries:
entry = entry.strip()
if not entry:
continue
if "—" in entry:
parts = entry.split("—", 1)
elif "--" in entry:
parts = entry.split("--", 1)
else:
continue
if len(parts) == 2:
word = parts[0].strip()
meaning = parts[1].strip()
# Clean word: remove trailing/leading punctuation
word = word.strip("., ")
if word and meaning:
result.append({"word": word, "meanings": [meaning]})
return result
def pair_padas(padas: list) -> str:
"""Pair pādas into verse lines (2 pādas per line).
A śloka has 4 pādas displayed as 2 lines. If there's an odd number,
the first pāda stands alone (usually a speaker attribution).
"""
if not padas:
return ""
lines = []
i = 0
# If odd number of pādas, first one is standalone
if len(padas) % 2 == 1:
lines.append(padas[0])
i = 1
# Pair remaining pādas
while i < len(padas) - 1:
lines.append(padas[i] + " " + padas[i + 1])
i += 2
# Handle any trailing single pāda
if i < len(padas):
lines.append(padas[i])
return "\n".join(lines)
def get_css_class(tag: Tag) -> str:
"""Get the primary CSS class of a tag."""
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 is_dashed_line(text: str) -> bool:
"""Check if text is a dashed-line separator."""
clean = text.strip().replace("-", "").replace("—", "").strip()
return clean == ""
def parse_verse_bookmark(text: str) -> int | None:
"""Parse a verse bookmark like 'Iso Invocation-1969' or 'Iso 1-1969'.
Returns mantra number (0 for Invocation, 1-18 for mantras), or None.
"""
text = text.strip()
if text == "Iso Invocation-1969":
return 0
m = re.match(r"Iso\s+(\d+)-1969", text)
if m:
return int(m.group(1))
return None
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_iso_boundaries(all_paragraphs: list) -> tuple[int, int]:
"""Find the start and end paragraph indices of the Iso content.
Start: <p class="p12"> containing "Īśopaniṣad" (ScaGoudy encoded)
End: next <p class="p12"> after start (start of next book), or end of file
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = p.get_text().strip()
# The ScaGoudy-encoded title contains "Éçopaniñad"
if "opani" in text.lower() and "1969" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after Iso start = end of Iso
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Śrī Īśopaniṣad - 1969 Edition' header in other.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_section_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
"""Find the key p13 bookmarks for Iso section boundaries.
Returns dict with bookmark text -> paragraph index.
"""
bookmarks = {}
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == BOOKMARK_CLASS:
text = decode_text(p)
if "Iso" in text and "1969" in text:
bookmarks[text] = i
return bookmarks
def find_verse_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all p49 verse bookmark tags in the Iso range.
Returns list of (paragraph_index, mantra_number).
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == VERSE_BOOKMARK_CLASS:
text = decode_text(p)
mantra_num = parse_verse_bookmark(text)
if mantra_num is not None:
bookmarks.append((i, mantra_num))
return bookmarks
# ---------------------------------------------------------------------------
# Intro segment extraction
# ---------------------------------------------------------------------------
def extract_intro_segment(
all_paragraphs: list, start_idx: int, end_idx: int
) -> list[str]:
"""Extract paragraphs for an intro segment (front pages or about the author).
Decodes ScaGoudy, preserves HTML formatting, skips dashed lines and empty paragraphs.
"""
result = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if is_empty(p):
continue
# Skip bookmark and header classes
if cls in (BOOKMARK_CLASS, BOOK_HEADER_CLASS, VERSE_BOOKMARK_CLASS):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
html = decode_html_content(p)
if not html:
continue
# Skip dashed-line separators
plain = re.sub(r"<[^>]+>", "", html).strip()
if is_dashed_line(plain):
continue
result.append(html)
return result
# ---------------------------------------------------------------------------
# Verse extraction state machine
# ---------------------------------------------------------------------------
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
"""Extract verses from the Iso Texts section using a state machine.
Returns list of verse dicts.
"""
verses = []
current_verse = None
state = "IDLE"
for i in range(verses_start, verses_end):
p = all_paragraphs[i]
cls = get_css_class(p)
# --- Verse bookmark (p49) ---
if cls == VERSE_BOOKMARK_CLASS:
# Finalize previous verse if any
if current_verse and state != "IDLE":
_finalize_verse(current_verse)
verses.append(current_verse)
text = decode_text(p)
mantra_num = parse_verse_bookmark(text)
if mantra_num is not None:
title = MANTRA_TITLES.get(mantra_num, f"Mantra {mantra_num}")
if mantra_num == 0:
reference = "Invocation"
else:
reference = f"Īśo {mantra_num}"
current_verse = {
"book": "iso",
"book_title": "Śrī Īśopaniṣad",
"mantra": mantra_num,
"title": title,
"reference": reference,
"devanagari": "",
"transliteration": "",
"_padas": [],
"synonyms": [],
"translation": "",
"purport": [],
}
state = "VERSE_START"
continue
if not current_verse:
continue
# --- Verse heading (p50) ---
if cls == VERSE_HEADING_CLASS:
# Just confirms the mantra title; skip
continue
# --- RM Devanagari (p51) — SKIP ---
if cls == DEVANAGARI_CLASS:
continue
# --- TEXT label (p52) — skip ---
if cls == TEXT_LABEL_CLASS:
state = "TRANSLITERATION"
continue
# --- Invocation transliteration (p53) — single <p> with <br> ---
if cls == INVOCATION_TRANSLIT_CLASS and current_verse["mantra"] == 0:
state = "TRANSLITERATION"
# Split on <br> tags to get individual pādas
# Get inner HTML and split on <br> variants
inner_html = "".join(str(child) for child in p.children)
# Remove <i> tags
inner_html = re.sub(r"</?i>", "", inner_html)
# Split on <br> or <br/> or <br />
raw_padas = re.split(r"<br\s*/?>", inner_html)
for pada in raw_padas:
# Decode ScaGoudy on plain text
pada_text = BeautifulSoup(pada, "html.parser").get_text()
pada_text = decode_scagoudy(pada_text).strip()
# Remove trailing period
pada_text = pada_text.rstrip(".")
if pada_text:
current_verse["_padas"].append(pada_text)
continue
# --- Pāda transliteration lines (p5) ---
if cls == PADA_TRANSLIT_CLASS and state in (
"VERSE_START",
"TRANSLITERATION",
):
state = "TRANSLITERATION"
line = decode_text(p)
# Remove trailing period
line = line.rstrip(".")
# Strip leading/trailing whitespace
line = line.strip()
if line:
current_verse["_padas"].append(line)
continue
# --- Synonyms (p54, p59) ---
if cls in SYNONYM_CLASSES and current_verse:
state = "SYNONYMS"
syn_text = decode_text(p)
# Remove leading/trailing italic markers that might come through
syn_text = syn_text.strip()
parsed = parse_synonyms(syn_text)
current_verse["synonyms"].extend(parsed)
continue
# --- TRANSLATION label (p55) ---
if cls == TRANSLATION_LABEL_CLASS and current_verse:
state = "TRANSLATION"
continue
# --- Translation text (p56) ---
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
html = decode_html_content(p)
# Remove bold wrapper if present (the whole translation is bold)
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
# Remove trailing source references like [& Bṛhad-āraṇyaka Upaniṣad 5.1]
# These are in span.s4 tags — they may have survived decode_html_content
html = html.strip()
current_verse["translation"] = html
state = "POST_TRANSLATION"
continue
# --- PURPORT label (p57) ---
if cls == PURPORT_LABEL_CLASS and current_verse:
state = "PURPORT"
continue
# --- Purport paragraphs ---
if state == "PURPORT" and current_verse and cls in PURPORT_CLASSES:
html = decode_html_content(p)
if html:
# Skip dashed-line separators
plain = re.sub(r"<[^>]+>", "", html).strip()
if not is_dashed_line(plain):
current_verse["purport"].append(html)
continue
# --- Section heading within purport (p31) ---
if state == "PURPORT" and current_verse and cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Body text classes that might appear in purport (p6) ---
if state == "PURPORT" and current_verse and cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
plain = re.sub(r"<[^>]+>", "", html).strip()
if not is_dashed_line(plain):
current_verse["purport"].append(html)
continue
# Finalize last verse
if current_verse and state != "IDLE":
_finalize_verse(current_verse)
verses.append(current_verse)
return verses
def _finalize_verse(verse: dict):
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
padas = verse.get("_padas", [])
# Transliteration: one pāda per line
verse["transliteration"] = "\n".join(padas)
# Devanagari: pair pādas into 2-line verse structure, then convert
if padas:
paired = pair_padas(padas)
verse["devanagari"] = iast_to_devanagari(paired)
# Clean up internal field
if "_padas" in verse:
del verse["_padas"]
# ---------------------------------------------------------------------------
# JSON writing
# ---------------------------------------------------------------------------
def write_json(data, filepath):
"""Write JSON with proper Unicode and formatting."""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f" Written: {filepath}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "iso")
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")
all_paragraphs = soup.find_all("p")
print(f"Total paragraphs in file: {len(all_paragraphs)}")
# --- Find Iso boundaries ---
iso_start, iso_end = find_iso_boundaries(all_paragraphs)
print(f"Iso content: paragraphs {iso_start} to {iso_end}")
# --- Find section bookmarks (p13) ---
section_bookmarks = find_section_bookmarks(all_paragraphs, iso_start, iso_end)
print(f"\nSection bookmarks found:")
for bm_text, bm_idx in sorted(section_bookmarks.items(), key=lambda x: x[1]):
print(f" [{bm_idx}] {bm_text}")
# Key bookmark indices
front_pages_idx = section_bookmarks.get("Iso back cover & front pages-1969")
introduction_idx = section_bookmarks.get("Iso Introduction-1969")
texts_idx = section_bookmarks.get("Iso Texts-1969")
about_idx = section_bookmarks.get("Iso About the author-1969")
glossary_idx = section_bookmarks.get("Iso Glossary-1969")
if texts_idx is None:
print("ERROR: Could not find 'Iso Texts-1969' bookmark!")
sys.exit(1)
# Use iso_end as fallback for glossary boundary
end_boundary = glossary_idx if glossary_idx is not None else iso_end
# --- Extract intro segments ---
print("\n--- Extracting Intro Segments ---")
intro_segments = []
# 1. Front pages segment (up to the Introduction bookmark, or texts if no intro)
front_end = introduction_idx if introduction_idx is not None else texts_idx
if front_pages_idx is not None and front_end is not None:
front_paras = extract_intro_segment(all_paragraphs, front_pages_idx, front_end)
front_segment = {
"slug": "front-pages",
"title": "Back Cover & Front Pages",
"type": "intro-segment",
"book": "iso",
"order": 1,
"paragraphs": front_paras,
}
intro_segments.append(front_segment)
print(f" Front Pages: {len(front_paras)} paragraphs")
# 2. Introduction (Teachings of the Vedas)
if introduction_idx is not None and texts_idx is not None:
intro_paras = extract_intro_segment(all_paragraphs, introduction_idx, texts_idx)
intro_segment = {
"slug": "introduction",
"title": "Introduction (Teachings of the Vedas)",
"type": "intro-segment",
"book": "iso",
"order": 2,
"paragraphs": intro_paras,
}
intro_segments.append(intro_segment)
print(f" Introduction: {len(intro_paras)} paragraphs")
# 3. About the Author segment
if about_idx is not None:
about_paras = extract_intro_segment(all_paragraphs, about_idx, end_boundary)
about_segment = {
"slug": "about-the-author",
"title": "About the Author",
"type": "intro-segment",
"book": "iso",
"order": 3,
"paragraphs": about_paras,
}
intro_segments.append(about_segment)
print(f" About the Author: {len(about_paras)} paragraphs")
# --- Extract verses ---
print("\n--- Extracting Verses ---")
# Verse section runs from texts_idx to about_idx (or end_boundary)
verse_end = about_idx if about_idx is not None else end_boundary
verses = extract_verses(all_paragraphs, texts_idx, verse_end)
print(f"Extracted {len(verses)} verses (expected 19: Invocation + Mantras 1-18)")
# --- Write intro.json ---
mantras_listing = []
for num in range(0, 19):
title = MANTRA_TITLES[num]
if num == 0:
slug = "invocation"
else:
slug = str(num).zfill(2)
mantras_listing.append({"number": num, "title": title, "slug": slug})
intro_meta = {
"book": "iso",
"title": "Śrī Īśopaniṣad",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1969,
"description": "The foremost of the 108 Upaniṣads, containing the essence of all Vedic knowledge.",
"totalMantras": 18,
"mantras": mantras_listing,
}
write_json(intro_meta, os.path.join(output_base, "intro.json"))
# --- Write intro segments ---
print("\n--- Writing Intro Segments ---")
for seg in intro_segments:
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
write_json(seg, filepath)
# --- Write verse JSON files ---
print("\n--- Writing Verse Files ---")
for verse in verses:
mantra_num = verse["mantra"]
if mantra_num == 0:
filename = "invocation.json"
else:
filename = f"{str(mantra_num).zfill(2)}.json"
filepath = os.path.join(output_base, "verses", filename)
write_json(verse, filepath)
# --- Summary ---
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Book: Śrī Īśopaniṣad (1969 Edition)")
print(f" Intro segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Verses extracted: {len(verses)}")
print(
f" Invocation: {'Yes' if any(v['mantra'] == 0 for v in verses) else 'No'}"
)
mantras_found = sorted([v["mantra"] for v in verses if v["mantra"] > 0])
print(
f" Mantras: {mantras_found[0]}-{mantras_found[-1]} ({len(mantras_found)} total)"
if mantras_found
else " Mantras: None"
)
total_purport_paras = sum(len(v["purport"]) for v in verses)
total_synonyms = sum(len(v["synonyms"]) for v in verses)
print(f" Total purport ¶s: {total_purport_paras}")
print(f" Total synonyms: {total_synonyms}")
print(f" Output directory: {output_base}/")
# --- Quality checks ---
print(f"\n--- Quality Checks ---")
# Check all verses have expected fields
issues = []
for verse in verses:
ref = verse["reference"]
if not verse["transliteration"]:
issues.append(f" {ref}: missing transliteration")
if not verse["devanagari"]:
issues.append(f" {ref}: missing devanagari")
if not verse["synonyms"]:
issues.append(f" {ref}: missing synonyms")
if not verse["translation"]:
issues.append(f" {ref}: missing translation")
if not verse["purport"]:
issues.append(f" {ref}: missing purport")
if issues:
print(" ISSUES FOUND:")
for issue in issues:
print(issue)
else:
print(
" All verses have transliteration, devanagari, synonyms, translation, and purport ✓"
)
# --- Samples ---
print(f"\n--- Sample: Invocation ---")
inv = [v for v in verses if v["mantra"] == 0]
if inv:
v = inv[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:80]}...")
print(f" Devanagari: {v['devanagari'][:80]}...")
print(f" Synonyms: {len(v['synonyms'])} entries")
if v["synonyms"]:
print(f" First: {v['synonyms'][0]}")
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Mantra One ---")
m1 = [v for v in verses if v["mantra"] == 1]
if m1:
v = m1[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:80]}...")
print(f" Devanagari: {v['devanagari'][:80]}...")
print(f" Synonyms: {len(v['synonyms'])} entries")
if v["synonyms"]:
print(f" First: {v['synonyms'][0]}")
print(f" Last: {v['synonyms'][-1]}")
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Mantra Eighteen (last) ---")
m18 = [v for v in verses if v["mantra"] == 18]
if m18:
v = m18[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:80]}...")
print(f" Devanagari: {v['devanagari'][:80]}...")
print(f" Synonyms: {len(v['synonyms'])} entries")
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
if __name__ == "__main__":
main()
+644
View File
@@ -0,0 +1,644 @@
#!/usr/bin/env python3
"""Extract Kṛṣṇa Consciousness: The Matchless Gift (1974) from other.html.
The book has 1 intro segment (front-pages) and 7 chapters.
Output:
lib/jsons/books/mg/intro.json — Book metadata
lib/jsons/books/mg/intro/*.json — Intro segments
lib/jsons/books/mg/chapters/*.json — Chapters
Usage:
python3 scripts/mg_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for MG
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6", "p78", "p30"])
# Sanskrit verse pāda classes (italic, centered) — BOTH p53 AND p24
VERSE_PADA_CLASSES = frozenset(["p53", "p24"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Book list header/footer
BOOK_LIST_CLASSES = frozenset(["p76", "p77"])
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p70", "p79"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_mg_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the MG content.
Start: <p class="p12"> containing "Matchless Gift" and "1974"
End: next <p class="p12"> after start, or <p class="p27"> containing "APPENDIX"
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Matchless Gift" in text and "1974" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after MG start = end of MG
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Kṛṣṇa Consciousness: The Matchless Gift (1974)' header in other.html!"
)
sys.exit(1)
# Also check for APPENDIXES/APPENDIX as end boundary (p27 with bold text)
for i in range(start_idx + 1, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_HEADING_CLASS:
text = p.get_text().strip().upper()
if text.startswith("APPENDIX") or text.startswith("APPENDIXES"):
end_idx = i
break
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all MG chapter bookmark tags (p13) in the MG range.
Returns list of (paragraph_index, chapter_number, 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)
# Use re.DOTALL to handle titles that span multiple lines
m = re.match(r"MG\s+(\d+)\s*:\s*(.+)", text, re.DOTALL)
if m:
ch_num = int(m.group(1))
# Clean the title: replace newlines with spaces, normalize whitespace
ch_title = re.sub(r"\s+", " ", m.group(2)).strip()
# Capitalize "love" in "Learning to love" -> "Learning to Love"
if ch_title == "Learning to love":
ch_title = "Learning to Love"
bookmarks.append((i, ch_num, ch_title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the MG content.
Groups consecutive p53/p24 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53 and p24) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Book list classes (p76, p77) — include in front-pages
if cls in BOOK_LIST_CLASSES:
if is_front_pages:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6, p78, p30)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_mg(soup: BeautifulSoup) -> dict:
"""Extract the entire Kṛṣṇa Consciousness: The Matchless Gift (1974) 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 MG boundaries
mg_start, mg_end = find_mg_boundaries(all_paragraphs)
print(f" MG content: paragraphs {mg_start} to {mg_end}")
print(f" Total paragraphs in range: {mg_end - mg_start}")
# Step 2: Find all chapter bookmarks
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, mg_start, mg_end)
print(f" Found {len(chapter_bookmarks)} MG chapter bookmarks")
for pos, ch_num, ch_title in chapter_bookmarks:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (mg_start) to first chapter bookmark (MG 1)
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = mg_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, mg_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "mg",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or mg_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = mg_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "mg",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "mg")
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 Kṛṣṇa Consciousness: The Matchless Gift (1974)...\n")
result = extract_mg(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "mg",
"title": "Kṛṣṇa Consciousness: The Matchless Gift",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1974,
"description": "A profound introduction to the science of Kṛṣṇa consciousness and the matchless gift of love of God.",
"totalChapters": 7,
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 7 chapters present
expected_chapters = set(range(1, 8))
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 7 chapters present")
# Check all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+803
View File
@@ -0,0 +1,803 @@
#!/usr/bin/env python3
"""Extract The Nectar of Devotion (1970 Edition) from other.html.
The book has 3 intro segments (Front Flap, Preface, Introduction),
51 chapters organized in 4 parts, and a Concluding Words section.
Output:
lib/jsons/books/nod/intro.json — Book metadata
lib/jsons/books/nod/intro/*.json — Intro segments
lib/jsons/books/nod/chapters/*.json — Chapters + concluding words
Usage:
python3 scripts/nod_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, 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 with ScaGoudy decoding."""
return decode_scagoudy(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 NoD
# ---------------------------------------------------------------------------
# Bookmark class — used for section boundary detection
BOOKMARK_CLASS = "p13"
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Body paragraph classes
BODY_CLASSES = frozenset(["p6", "p35", "p30"])
# Section heading (bold) — "Chapter One", "Preface", etc.
SECTION_HEADING_CLASS = "p31"
# Chapter title (bold) — "Characteristics of Pure Devotional Service", etc.
CHAPTER_TITLE_CLASS = "p27"
# Part header class — "PART ONE", "PART TWO", etc.
PART_HEADER_CLASS = "p34"
# Sub-section heading (bold) within chapters
SUB_HEADING_CLASS = "p36"
# Italic sub-headings
ITALIC_SUB_HEADING_CLASSES = frozenset(["p37", "p38"])
# Title page / copyright text classes (in intro area)
TITLE_PAGE_CLASS = "p3"
COPYRIGHT_CLASS = "p32"
# Sanskrit verse (italic with br tags)
SANSKRIT_VERSE_CLASS = "p33"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p15"])
# ---------------------------------------------------------------------------
# Intro segment definitions
# ---------------------------------------------------------------------------
INTRO_SEGMENTS = [
{
"slug": "front-flap",
"title": "Front Flap",
"order": 1,
"bookmark_pattern": "NoD-1970: Front Flap",
},
{
"slug": "preface",
"title": "Preface",
"order": 2,
"bookmark_pattern": "NoD-1970: Preface",
},
{
"slug": "introduction",
"title": "Introduction",
"order": 3,
"bookmark_pattern": "NoD-1970: Introduction",
},
]
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_nod_boundaries(all_paragraphs: list) -> tuple[int, int]:
"""Find the start and end paragraph indices of the NoD content.
Start: <p class="p12"> containing "Nectar of Devotion"
End: next <p class="p12"> after start, or end of file
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Nectar of Devotion" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after NoD start = end of NoD
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'The Nectar of Devotion' header in other.html!")
sys.exit(1)
return start_idx, end_idx
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all p13 bookmark tags in the NoD range.
Returns list of (paragraph_index, bookmark_text).
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == BOOKMARK_CLASS:
text = decode_text(p)
if "NoD" in text:
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str) -> tuple[int, str] | None:
"""Parse a chapter bookmark like 'NoD 1-1970: Characteristics of Pure Devotional Service'.
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"NoD\s+(\d+)-1970:\s*(.+)", text.strip())
if m:
return int(m.group(1)), m.group(2).strip()
return None
def parse_intro_bookmark(text: str) -> str | None:
"""Parse an intro bookmark like 'NoD-1970: Preface'.
Returns the segment name or None if not an intro bookmark.
"""
m = re.match(r"NoD-1970:\s*(.+)", text.strip())
if m:
return m.group(1).strip()
return None
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_intro: bool = False,
) -> list[str]:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the NoD content.
"""
result: list[str] = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags
if cls == BOOKMARK_CLASS:
continue
# Skip book title header
if cls == BOOK_HEADER_CLASS:
continue
# Part headers — include as bold paragraph
if cls == PART_HEADER_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Section heading (p31) — include as paragraph
if cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Chapter title (p27) — include as bold paragraph
if cls == CHAPTER_TITLE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Sub-section headings within chapters (p36, bold)
if cls == SUB_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Italic sub-headings (p37, p38)
if cls in ITALIC_SUB_HEADING_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page / copyright text (in intro area)
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Copyright text
if cls == COPYRIGHT_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Sanskrit verse (italic with br tags) — format as centered italic div
# Strip all inner <i>/<\/i> tags before re-wrapping to avoid nesting
if cls == SANSKRIT_VERSE_CLASS:
html = decode_html_content(p)
if html:
stripped = re.sub(r"</?i>", "", html)
result.append(f'<div style="text-align:center"><i>{stripped}</i></div>')
continue
# Body paragraphs (p6, p35, p30)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html:
result.append(html)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_nod(soup: BeautifulSoup) -> dict:
"""Extract the entire Nectar of Devotion from the parsed HTML.
Returns a dict with:
"intro_segments": list of intro segment dicts
"chapters": list of chapter dicts
"concluding_words": concluding words dict
"parts": list of part assignment dicts
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find NoD boundaries
nod_start, nod_end = find_nod_boundaries(all_paragraphs)
print(f" NoD content: paragraphs {nod_start} to {nod_end}")
print(f" Total paragraphs in range: {nod_end - nod_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, nod_start, nod_end)
print(f" Found {len(bookmarks)} NoD bookmarks")
# Step 3: Categorize bookmarks into intro vs chapter
intro_bookmarks = [] # (para_idx, segment_name)
chapter_bookmarks = [] # (para_idx, chapter_num, title)
for pos, text in bookmarks:
ch = parse_chapter_bookmark(text)
if ch:
chapter_bookmarks.append((pos, ch[0], ch[1]))
else:
intro_name = parse_intro_bookmark(text)
if intro_name:
intro_bookmarks.append((pos, intro_name))
print(f" Intro bookmarks: {len(intro_bookmarks)}")
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
# Step 4: Track PART assignments by scanning for p34 tags
# We need to know which chapters belong to which part.
part_assignments = {} # chapter_num -> part_num
parts_data = [] # list of {number, title, chapters}
# Walk through all paragraphs in NoD range and track parts
# We process this before extracting chapters so we know part assignments
part_positions = [] # (para_idx, part_title)
for i in range(nod_start, nod_end):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == PART_HEADER_CLASS:
text = decode_text(p)
if text:
part_positions.append((i, text))
print(f" Part headers found: {len(part_positions)}")
for pos, title in part_positions:
print(f" Line ~{pos}: {title}")
# Assign parts to chapters based on position
# A chapter belongs to the most recent PART header before its bookmark
part_number_map = {}
for pos, title in part_positions:
# Extract part number from title like "PART ONE", "PART TWO", etc.
part_num = None
title_upper = title.upper()
if "ONE" in title_upper or "I" == title_upper.replace("PART", "").strip():
part_num = 1
elif "TWO" in title_upper or "II" == title_upper.replace("PART", "").strip():
part_num = 2
elif "THREE" in title_upper or "III" == title_upper.replace("PART", "").strip():
part_num = 3
elif "FOUR" in title_upper or "IV" == title_upper.replace("PART", "").strip():
part_num = 4
if part_num:
part_number_map[pos] = part_num
# Now assign each chapter to a part
sorted_part_positions = sorted(part_number_map.keys())
for ch_pos, ch_num, ch_title in chapter_bookmarks:
# Find the most recent PART header before this chapter's bookmark
assigned_part = None
for pp in sorted_part_positions:
if pp < ch_pos:
assigned_part = part_number_map[pp]
else:
break
if assigned_part:
part_assignments[ch_num] = assigned_part
# Build parts_data
for part_num in sorted(set(part_assignments.values())):
chs = sorted([ch for ch, pn in part_assignments.items() if pn == part_num])
part_title = f"Part {'One' if part_num == 1 else 'Two' if part_num == 2 else 'Three' if part_num == 3 else 'Four'}"
parts_data.append(
{
"number": part_num,
"title": part_title,
"chapters": chs,
}
)
# Step 5: Extract intro segments
intro_segments = []
for seg_def in INTRO_SEGMENTS:
# Find matching bookmark
bookmark_pos = None
for pos, name in intro_bookmarks:
if (
seg_def["bookmark_pattern"].endswith(name)
or name == seg_def["bookmark_pattern"].split(": ", 1)[-1]
):
bookmark_pos = pos
break
if bookmark_pos is None:
print(f" WARNING: Could not find bookmark for '{seg_def['slug']}'")
continue
# Content starts after the bookmark
content_start = bookmark_pos + 1
# Content ends at the next bookmark (intro or chapter) or PART header
content_end = nod_end
all_section_starts = sorted(
[pos for pos, _ in intro_bookmarks]
+ [pos for pos, _, _ in chapter_bookmarks]
)
for s in all_section_starts:
if s > bookmark_pos:
content_end = s
break
paragraphs = extract_paragraphs(
all_paragraphs, content_start, content_end, is_intro=True
)
seg = {
"slug": seg_def["slug"],
"title": seg_def["title"],
"type": "intro-segment",
"book": "nod",
"order": seg_def["order"],
"paragraphs": paragraphs,
}
intro_segments.append(seg)
# Step 6: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or "Concluding Words" p27 or nod_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
# Last chapter (51) — content ends at "Concluding Words" p27 heading
# or at nod_end. We need to find where "Concluding Words" starts.
# Look for p27 containing "Concluding Words" after the last chapter bookmark.
concluding_pos = None
for i in range(ch_pos + 1, nod_end):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_TITLE_CLASS:
text = decode_text(p)
if "Concluding Words" in text:
concluding_pos = i
break
if concluding_pos:
content_end = concluding_pos
else:
content_end = nod_end
# For the PART header: if a PART header appears right before this
# chapter's bookmark, include it in this chapter's content range.
# The PART header might be 1-3 paragraphs before the bookmark (with
# possible p15 empty lines in between).
actual_start = content_start
for pp in sorted_part_positions:
# Check if this PART header is between the previous section end and
# this chapter's bookmark
if pp < ch_pos and pp >= content_start - 5:
# Check that there's no other bookmark between pp and ch_pos
has_other_bookmark = False
for bpos, _ in bookmarks:
if bpos > pp and bpos < ch_pos:
has_other_bookmark = True
break
if not has_other_bookmark:
actual_start = pp
paragraphs = extract_paragraphs(all_paragraphs, actual_start, content_end)
ch = {
"book": "nod",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 7: Extract "Concluding Words"
concluding_words = None
# Find the "Concluding Words" p27 heading after chapter 51
last_ch_pos = chapter_bookmarks[-1][0] if chapter_bookmarks else nod_start
for i in range(last_ch_pos, nod_end):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_TITLE_CLASS:
text = decode_text(p)
if "Concluding Words" in text:
# Extract from this heading to nod_end
paragraphs = extract_paragraphs(all_paragraphs, i, nod_end)
concluding_words = {
"book": "nod",
"chapter": None,
"title": "Concluding Words",
"type": "conclusion",
"slug": "concluding-words",
"paragraphs": paragraphs,
}
break
# Step 8: 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,
"concluding_words": concluding_words,
"parts": parts_data,
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "nod")
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 The Nectar of Devotion (1970 Edition)...\n")
result = extract_nod(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
concluding_words = result["concluding_words"]
parts = result["parts"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "nod",
"title": "The Nectar of Devotion",
"subtitle": "A Summary Study of Śrīla Rūpa Gosvāmī's Bhakti-rasāmṛta-sindhu",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1970,
"description": "The complete science of bhakti-yoga, or devotional service to Lord Kṛṣṇa.",
"totalChapters": 51,
"parts": parts,
"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)
part_info = ""
for p in parts:
if ch["chapter"] in p["chapters"]:
part_info = f" [Part {p['number']}]"
break
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}{part_info}"
)
# --- Write concluding words ---
if concluding_words:
filepath = os.path.join(output_base, "chapters", "concluding-words.json")
write_json(concluding_words, filepath)
print(
f"\n Concluding Words: {len(concluding_words['paragraphs'])} paragraphs → {filepath}"
)
# --- 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_concluding_paras = (
len(concluding_words["paragraphs"]) if concluding_words else 0
)
total_paras = total_intro_paras + total_chapter_paras + total_concluding_paras
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Intro segments: {len(intro_segments)}")
print(f" Chapters: {len(chapters)}")
print(f" Concluding Words: {'Yes' if concluding_words else 'No'}")
print(f" Parts: {len(parts)}")
for p in parts:
print(
f" Part {p['number']} ({p['title']}): chapters {p['chapters'][0]}-{p['chapters'][-1]} ({len(p['chapters'])} chapters)"
)
print(f" Total paragraphs: {total_paras}")
print(f" Intro: {total_intro_paras}")
print(f" Chapters: {total_chapter_paras}")
print(f" Concluding: {total_concluding_paras}")
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
print(f"\n--- Sample: Chapter 1 ---")
if chapters:
ch1 = chapters[0]
for i, p in enumerate(ch1["paragraphs"][:4]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
print(f"\n--- Sample: Chapter {chapters[-1]['chapter']} (last) ---")
if chapters:
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if concluding_words:
print(f"\n--- Sample: Concluding Words ---")
for i, p in enumerate(concluding_words["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 51 chapters present
expected_chapters = set(range(1, 52))
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 51 chapters present")
# Check all 3 intro segments present
expected_intros = {"front-flap", "preface", "introduction"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
else:
print(f" ✅ All 3 intro segments present")
# Check "Thus ends" markers in chapters
chapters_with_end = 0
for ch in chapters:
has_end = any("Thus end" in p for p in ch["paragraphs"])
if has_end:
chapters_with_end += 1
print(f" ℹ {chapters_with_end}/{len(chapters)} chapters have 'Thus ends' markers")
# 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 — look for common encoded characters that should have been decoded
# Note: ñ is excluded because it's a valid IAST output character (ï→ñ mapping)
encoded_chars = set("äåöëòçüéàùè")
suspect_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
# Only flag if the encoded chars appear outside HTML tags
plain = re.sub(r"<[^>]+>", "", p)
for c in encoded_chars:
if c in plain:
# Many of these are legitimate uses (e.g., German loanwords)
# but in this context they almost always indicate ScaGoudy encoding
suspect_count += 1
break
if suspect_count == 0:
print(f" ✅ No suspected undecoded ScaGoudy characters found")
else:
print(
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
)
# Check part assignments
all_part_chapters = set()
for p in parts:
all_part_chapters.update(p["chapters"])
unassigned = [
ch["chapter"] for ch in chapters if ch["chapter"] not in all_part_chapters
]
if unassigned:
print(f" ⚠ Chapters not assigned to any part: {unassigned}")
else:
print(f" ✅ All chapters assigned to parts")
# Verify ScaGoudy decoding with a spot check on known text
print(f"\n--- ScaGoudy Decoding Spot Check ---")
# Chapter 1 should contain "Kṛṣṇa" (encoded as "Kåñëa")
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"][:5])
if "Kṛṣṇa" in ch1_text:
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
elif "Kåñëa" in ch1_text:
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
else:
print(
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 5 paragraphs (may be normal)"
)
# Check for "Śrīmad-Bhāgavatam" (encoded as "Çrémad-Bhägavatam")
if "Śrīmad-Bhāgavatam" in ch1_text:
print(f" ✅ 'Śrīmad-Bhāgavatam' correctly decoded")
elif "Çrémad-Bhägavatam" in ch1_text:
print(f" ⚠ ScaGoudy decoding incomplete for 'Çrémad-Bhägavatam'")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+859
View File
@@ -0,0 +1,859 @@
#!/usr/bin/env python3
"""Extract The Nectar of Instruction (1976 Edition) verses from other.html into structured JSON files."""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
from indic_transliteration import sanscript
from indic_transliteration.sanscript import transliterate
# --- ScaGoudy → IAST Unicode mapping ---
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ", # long vocalic r (U+1E5D)
"û": "ṁl", # anusvara + l (ScaGoudy ligature glyph)
"ÿ": "ḷ", # vocalic l (U+1E37)
}
# --- Text number to title mapping ---
TEXT_TITLES = {
1: "Text One",
2: "Text Two",
3: "Text Three",
4: "Text Four",
5: "Text Five",
6: "Text Six",
7: "Text Seven",
8: "Text Eight",
9: "Text Nine",
10: "Text Ten",
11: "Text Eleven",
}
# --- CSS class constants ---
BOOK_HEADER_CLASS = "p12"
SECTION_BOOKMARK_CLASS = "p64" # NoI section bookmarks (Front pages, Preface)
VERSE_BOOKMARK_CLASS = "p49" # NoI verse bookmarks (NoI: verse N) AND appendixes
VERSE_HEADING_CLASS = "p50"
DEVANAGARI_CLASS = "p51" # RM Devanagari — SKIP
TEXT_LABEL_CLASS = "p52"
PADA_TRANSLIT_CLASS = "p5"
SYNONYM_CLASS = "p54"
TRANSLATION_LABEL_CLASS = "p55"
TRANSLATION_TEXT_CLASS = "p56"
PURPORT_LABEL_CLASS = "p57"
PURPORT_BODY_CLASS = "p48"
QUOTED_VERSE_CLASSES = (
"p33",
"p53",
"p66",
) # Quoted Sanskrit/Bengali verses in purport
BODY_TEXT_CLASS = "p44" # Verse translations within purport
MISC_TEXT_CLASS = "p67" # Miscellaneous text (1 occurrence in NoI)
SECTION_HEADING_CLASS = "p31"
BODY_CLASSES = ("p6", "p45")
SKIP_CLASSES = ("p9", "p15", "p19", "p25", "p32", "p37", "p65")
def decode_scagoudy(text: str) -> str:
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (artifacts from RTF conversion)
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:
"""Extract plain text from a tag and decode ScaGoudy."""
text = tag.get_text()
return decode_scagoudy(text).strip()
def normalize_devanagari_anusvara(text: str) -> str:
"""Replace explicit class nasals before same-class consonants with anusvāra.
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
"""
replacements = [
# ङ् before velars (क, ख, ग, घ)
(r"ङ्([कखगघ])", r"ं\1"),
# ञ् before palatals (च, छ, ज, झ)
(r"ञ्([चछजझ])", r"ं\1"),
]
for pattern, repl in replacements:
text = re.sub(pattern, repl, text)
return text
def join_virama_words(text: str) -> str:
"""Join Devanagari words where the previous word ends with virāma (्).
Handles both virāma+consonant and virāma+vowel cases.
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
"""
# Join virāma + space + consonant (क U+0915 through ह U+0939)
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
# Join virāma + space + independent vowel
_VOWEL_TO_MATRA = {
"अ": "", # inherent vowel — just drop virāma and the अ
"आ": "ा",
"इ": "ि",
"ई": "ी",
"उ": "ु",
"ऊ": "ू",
"ऋ": "ृ",
"ॠ": "ॄ",
"ऌ": "ॢ",
"ॡ": "ॣ",
"ए": "े",
"ऐ": "ै",
"ओ": "ो",
"औ": "ौ",
}
def _virama_vowel_replace(m: re.Match[str]) -> str:
vowel = m.group(1)
return _VOWEL_TO_MATRA.get(vowel, vowel)
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
return text
def iast_to_devanagari(iast_text: str) -> str:
"""Convert IAST transliteration to Devanagari script."""
lines = iast_text.split("\n")
result_lines = []
for line in lines:
# Remove hyphens between words for Devanagari
clean = line.replace("-", "")
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
clean = clean.replace("ṁ", "ṃ")
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
deva = normalize_devanagari_anusvara(deva)
deva = join_virama_words(deva)
result_lines.append(deva)
return "\n".join(result_lines)
def parse_synonyms(text: str) -> list:
"""Parse synonym text into structured array.
Input format: "word—meaning; word—meaning; word—meaning"
"""
result = []
entries = text.split(";")
for entry in entries:
entry = entry.strip()
if not entry:
continue
if "—" in entry:
parts = entry.split("—", 1)
elif "--" in entry:
parts = entry.split("--", 1)
else:
continue
if len(parts) == 2:
word = parts[0].strip()
meaning = parts[1].strip()
# Clean word: remove trailing/leading punctuation
word = word.strip("., ")
if word and meaning:
result.append({"word": word, "meanings": [meaning]})
return result
def pair_padas(padas: list) -> str:
"""Pair pādas into verse lines (2 pādas per line).
A śloka has 4 pādas displayed as 2 lines. If there's an odd number,
the first pāda stands alone (usually a speaker attribution).
"""
if not padas:
return ""
lines = []
i = 0
# If odd number of pādas, first one is standalone
if len(padas) % 2 == 1:
lines.append(padas[0])
i = 1
# Pair remaining pādas
while i < len(padas) - 1:
lines.append(padas[i] + " " + padas[i + 1])
i += 2
# Handle any trailing single pāda
if i < len(padas):
lines.append(padas[i])
return "\n".join(lines)
def get_css_class(tag: Tag) -> str:
"""Get the primary CSS class of a tag."""
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 is_dashed_line(text: str) -> bool:
"""Check if text is a dashed-line separator."""
clean = text.strip().replace("-", "").replace("—", "").strip()
return clean == ""
def parse_verse_bookmark(text: str) -> int | None:
"""Parse a verse bookmark like 'NoI: verse 1' through 'NoI: verse 11'.
Returns text number (1-11), or None if not a verse bookmark.
"""
text = text.strip()
m = re.match(r"NoI:\s*verse\s+(\d+)", text)
if m:
return int(m.group(1))
return None
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_noi_boundaries(all_paragraphs: list) -> tuple[int, int]:
"""Find the start and end paragraph indices of the NoI content.
Start: <p class="p12"> containing "Nectar of Instruction" and "1976"
End: next <p class="p12"> after start (start of next book), or end of file
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = p.get_text().strip()
if "Nectar of Instruction" in text and "1976" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after NoI start = end of NoI
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'The Nectar of Instruction - 1976 Edition' header in other.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_section_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
"""Find the key p64 bookmarks for NoI section boundaries.
Returns dict with bookmark text -> paragraph index.
"""
bookmarks = {}
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == SECTION_BOOKMARK_CLASS:
text = decode_text(p)
if "NoI" in text:
bookmarks[text] = i
return bookmarks
def find_verse_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all p49 verse bookmark tags in the NoI range.
Returns list of (paragraph_index, text_number) for actual verse bookmarks.
Also returns appendixes bookmark index separately.
"""
verse_bookmarks = []
appendixes_idx = None
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == VERSE_BOOKMARK_CLASS:
text = decode_text(p)
text_num = parse_verse_bookmark(text)
if text_num is not None:
verse_bookmarks.append((i, text_num))
elif text.strip() == "NoI Appendixes":
appendixes_idx = i
return verse_bookmarks, appendixes_idx
# ---------------------------------------------------------------------------
# Intro segment extraction
# ---------------------------------------------------------------------------
def extract_intro_segment(
all_paragraphs: list, start_idx: int, end_idx: int
) -> list[str]:
"""Extract paragraphs for an intro segment (front pages, preface, or appendixes).
Decodes ScaGoudy, preserves HTML formatting, skips dashed lines and empty paragraphs.
"""
result = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if is_empty(p):
continue
# Skip bookmark and header classes
if cls in (SECTION_BOOKMARK_CLASS, BOOK_HEADER_CLASS, VERSE_BOOKMARK_CLASS):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
html = decode_html_content(p)
if not html:
continue
# Skip dashed-line separators
plain = re.sub(r"<[^>]+>", "", html).strip()
if is_dashed_line(plain):
continue
result.append(html)
return result
# ---------------------------------------------------------------------------
# Verse extraction state machine
# ---------------------------------------------------------------------------
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
"""Extract verses from the NoI section using a state machine.
Returns list of verse dicts.
"""
verses = []
current_verse = None
state = "IDLE"
for i in range(verses_start, verses_end):
p = all_paragraphs[i]
cls = get_css_class(p)
# --- Verse bookmark (p49) ---
if cls == VERSE_BOOKMARK_CLASS:
# Finalize previous verse if any
if current_verse and state != "IDLE":
_finalize_verse(current_verse)
verses.append(current_verse)
text = decode_text(p)
text_num = parse_verse_bookmark(text)
if text_num is not None:
title = TEXT_TITLES.get(text_num, f"Text {text_num}")
reference = f"NoI {text_num}"
current_verse = {
"book": "noi",
"book_title": "The Nectar of Instruction",
"text": text_num,
"title": title,
"reference": reference,
"devanagari": "",
"transliteration": "",
"_padas": [],
"synonyms": [],
"translation": "",
"purport": [],
}
state = "VERSE_START"
else:
# Non-verse bookmark (e.g., "NoI Appendixes") — stop processing
current_verse = None
state = "IDLE"
continue
if not current_verse:
continue
# --- Verse heading (p50) ---
if cls == VERSE_HEADING_CLASS:
# Just confirms the text title; skip
continue
# --- RM Devanagari (p51) — SKIP ---
if cls == DEVANAGARI_CLASS:
continue
# --- TEXT label (p52) — skip ---
if cls == TEXT_LABEL_CLASS:
state = "TRANSLITERATION"
continue
# --- Pāda transliteration lines (p5) ---
if cls == PADA_TRANSLIT_CLASS and state in (
"VERSE_START",
"TRANSLITERATION",
):
state = "TRANSLITERATION"
line = decode_text(p)
# Remove trailing period
line = line.rstrip(".")
# Strip leading/trailing whitespace
line = line.strip()
if line:
current_verse["_padas"].append(line)
continue
# --- Synonyms (p54) ---
if cls == SYNONYM_CLASS and current_verse:
state = "SYNONYMS"
syn_text = decode_text(p)
syn_text = syn_text.strip()
parsed = parse_synonyms(syn_text)
current_verse["synonyms"].extend(parsed)
continue
# --- TRANSLATION / PURPORT label (p55) ---
if cls == TRANSLATION_LABEL_CLASS and current_verse:
label_text = decode_text(p).strip().upper()
if "TRANSLATION" in label_text:
state = "TRANSLATION"
elif "PURPORT" in label_text:
state = "PURPORT"
else:
# Other labels within purport (e.g., "The Author", "Glossary")
if state == "PURPORT":
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Translation text (p56) ---
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
html = decode_html_content(p)
# Remove bold wrapper if present (the whole translation is bold)
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
html = html.strip()
current_verse["translation"] = html
state = "POST_TRANSLATION"
continue
# --- PURPORT label (p57) ---
if cls == PURPORT_LABEL_CLASS and current_verse:
state = "PURPORT"
continue
# --- Purport body paragraphs (p48) ---
if state == "PURPORT" and current_verse and cls == PURPORT_BODY_CLASS:
html = decode_html_content(p)
if html:
plain = re.sub(r"<[^>]+>", "", html).strip()
if not is_dashed_line(plain):
current_verse["purport"].append(html)
continue
# --- Quoted Sanskrit/Bengali verses within purport (p33, p53, p66) ---
if state == "PURPORT" and current_verse and cls in QUOTED_VERSE_CLASSES:
html = decode_html_content(p)
if html:
# Strip inner <i>/<\/i> tags to avoid double wrapping
stripped = re.sub(r"</?i>", "", html)
current_verse["purport"].append(
f'<div style="text-align:center"><i>{stripped}</i></div>'
)
continue
# --- Body text / verse translations within purport (p44) ---
if state == "PURPORT" and current_verse and cls == BODY_TEXT_CLASS:
html = decode_html_content(p)
if html:
plain = re.sub(r"<[^>]+>", "", html).strip()
if not is_dashed_line(plain):
current_verse["purport"].append(html)
continue
# --- Miscellaneous text (p67) — treat as body text within purport ---
if state == "PURPORT" and current_verse and cls == MISC_TEXT_CLASS:
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Section heading within purport (p31) ---
if state == "PURPORT" and current_verse and cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Body text classes that might appear in purport (p6, p45) ---
if state == "PURPORT" and current_verse and cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
plain = re.sub(r"<[^>]+>", "", html).strip()
if not is_dashed_line(plain):
current_verse["purport"].append(html)
continue
# Finalize last verse
if current_verse and state != "IDLE":
_finalize_verse(current_verse)
verses.append(current_verse)
return verses
def _finalize_verse(verse: dict):
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
padas = verse.get("_padas", [])
# Transliteration: one pāda per line
verse["transliteration"] = "\n".join(padas)
# Devanagari: pair pādas into 2-line verse structure, then convert
if padas:
paired = pair_padas(padas)
verse["devanagari"] = iast_to_devanagari(paired)
# Clean up internal field
if "_padas" in verse:
del verse["_padas"]
# ---------------------------------------------------------------------------
# JSON writing
# ---------------------------------------------------------------------------
def write_json(data, filepath):
"""Write JSON with proper Unicode and formatting."""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f" Written: {filepath}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "noi")
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")
all_paragraphs = soup.find_all("p")
print(f"Total paragraphs in file: {len(all_paragraphs)}")
# --- Find NoI boundaries ---
noi_start, noi_end = find_noi_boundaries(all_paragraphs)
print(f"NoI content: paragraphs {noi_start} to {noi_end}")
# --- Find section bookmarks (p64) ---
section_bookmarks = find_section_bookmarks(all_paragraphs, noi_start, noi_end)
print(f"\nSection bookmarks (p64) found:")
for bm_text, bm_idx in sorted(section_bookmarks.items(), key=lambda x: x[1]):
print(f" [{bm_idx}] {bm_text}")
# --- Find verse bookmarks (p49) ---
verse_bookmarks, appendixes_idx = find_verse_bookmarks(
all_paragraphs, noi_start, noi_end
)
print(f"\nVerse bookmarks (p49) found: {len(verse_bookmarks)}")
for bm_idx, text_num in verse_bookmarks:
print(f" [{bm_idx}] NoI: verse {text_num}")
if appendixes_idx is not None:
print(f" [{appendixes_idx}] NoI Appendixes")
# Key bookmark indices
front_pages_idx = section_bookmarks.get("NoI Front pages")
preface_idx = section_bookmarks.get("NoI Preface")
# First verse bookmark
first_verse_idx = verse_bookmarks[0][0] if verse_bookmarks else None
if first_verse_idx is None:
print("ERROR: Could not find any verse bookmarks!")
sys.exit(1)
# End boundary: the Glossary <p class="p55"><b>Glossary</b></p> or noi_end
end_boundary = noi_end
# Look for the Glossary marker just before the next book
for i in range(noi_start, noi_end):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == TRANSLATION_LABEL_CLASS:
text = decode_text(p).strip()
if text.upper() == "GLOSSARY":
end_boundary = i
break
# --- Extract intro segments ---
print("\n--- Extracting Intro Segments ---")
intro_segments = []
# 1. Front pages segment — from NoI Front pages (p64) to NoI Preface (p64)
front_end = preface_idx if preface_idx is not None else first_verse_idx
if front_pages_idx is not None and front_end is not None:
front_paras = extract_intro_segment(all_paragraphs, front_pages_idx, front_end)
front_segment = {
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "noi",
"order": 1,
"paragraphs": front_paras,
}
intro_segments.append(front_segment)
print(f" Front Pages: {len(front_paras)} paragraphs")
# 2. Preface — from NoI Preface (p64) to first verse bookmark
if preface_idx is not None and first_verse_idx is not None:
preface_paras = extract_intro_segment(
all_paragraphs, preface_idx, first_verse_idx
)
preface_segment = {
"slug": "preface",
"title": "Preface",
"type": "intro-segment",
"book": "noi",
"order": 2,
"paragraphs": preface_paras,
}
intro_segments.append(preface_segment)
print(f" Preface: {len(preface_paras)} paragraphs")
# 3. Appendixes — from NoI Appendixes (p49) to end boundary
if appendixes_idx is not None:
appendixes_paras = extract_intro_segment(
all_paragraphs, appendixes_idx, end_boundary
)
appendixes_segment = {
"slug": "appendixes",
"title": "Appendixes",
"type": "intro-segment",
"book": "noi",
"order": 3,
"paragraphs": appendixes_paras,
}
intro_segments.append(appendixes_segment)
print(f" Appendixes: {len(appendixes_paras)} paragraphs")
# --- Extract verses ---
print("\n--- Extracting Verses ---")
# Verse section runs from first verse bookmark to appendixes (or end boundary)
verse_end = appendixes_idx if appendixes_idx is not None else end_boundary
verses = extract_verses(all_paragraphs, first_verse_idx, verse_end)
print(f"Extracted {len(verses)} texts (expected 11: Texts 1-11)")
# --- Write intro.json ---
texts_listing = []
for num in range(1, 12):
title = TEXT_TITLES[num]
slug = str(num).zfill(2)
texts_listing.append({"number": num, "title": title, "slug": slug})
intro_meta = {
"book": "noi",
"title": "The Nectar of Instruction",
"subtitle": "Śrī Upadeśāmṛta",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1976,
"description": "Eleven essential instructions on the science of bhakti-yoga by Śrīla Rūpa Gosvāmī.",
"totalTexts": 11,
"texts": texts_listing,
}
write_json(intro_meta, os.path.join(output_base, "intro.json"))
# --- Write intro segments ---
print("\n--- Writing Intro Segments ---")
for seg in intro_segments:
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
write_json(seg, filepath)
# --- Write verse JSON files ---
print("\n--- Writing Verse Files ---")
for verse in verses:
text_num = verse["text"]
filename = f"{str(text_num).zfill(2)}.json"
filepath = os.path.join(output_base, "verses", filename)
write_json(verse, filepath)
# --- Summary ---
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Book: The Nectar of Instruction (1976 Edition)")
print(f" Intro segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Texts extracted: {len(verses)}")
texts_found = sorted([v["text"] for v in verses])
if texts_found:
print(
f" Texts: {texts_found[0]}-{texts_found[-1]} ({len(texts_found)} total)"
)
else:
print(" Texts: None")
total_purport_paras = sum(len(v["purport"]) for v in verses)
total_synonyms = sum(len(v["synonyms"]) for v in verses)
print(f" Total purport ¶s: {total_purport_paras}")
print(f" Total synonyms: {total_synonyms}")
print(f" Output directory: {output_base}/")
# --- Quality checks ---
print(f"\n--- Quality Checks ---")
# Check all verses have expected fields
issues = []
for verse in verses:
ref = verse["reference"]
if not verse["transliteration"]:
issues.append(f" {ref}: missing transliteration")
if not verse["devanagari"]:
issues.append(f" {ref}: missing devanagari")
if not verse["synonyms"]:
issues.append(f" {ref}: missing synonyms")
if not verse["translation"]:
issues.append(f" {ref}: missing translation")
if not verse["purport"]:
issues.append(f" {ref}: missing purport")
if issues:
print(" ISSUES FOUND:")
for issue in issues:
print(issue)
else:
print(
" All texts have transliteration, devanagari, synonyms, translation, and purport ✓"
)
# Check expected text numbers are present
expected = set(range(1, 12))
found = set(texts_found)
missing = expected - found
extra = found - expected
if missing:
print(f" MISSING texts: {sorted(missing)}")
if extra:
print(f" UNEXPECTED texts: {sorted(extra)}")
if not missing and not extra:
print(f" All 11 texts present ✓")
# --- Samples ---
print(f"\n--- Sample: Text One ---")
t1 = [v for v in verses if v["text"] == 1]
if t1:
v = t1[0]
print(f" Reference: {v['reference']}")
print(f" Title: {v['title']}")
print(f" Transliteration: {v['transliteration'][:80]}...")
print(f" Devanagari: {v['devanagari'][:80]}...")
print(f" Synonyms: {len(v['synonyms'])} entries")
if v["synonyms"]:
print(f" First: {v['synonyms'][0]}")
print(f" Last: {v['synonyms'][-1]}")
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Text Six (middle) ---")
t6 = [v for v in verses if v["text"] == 6]
if t6:
v = t6[0]
print(f" Reference: {v['reference']}")
print(f" Title: {v['title']}")
print(f" Transliteration: {v['transliteration'][:80]}...")
print(f" Devanagari: {v['devanagari'][:80]}...")
print(f" Synonyms: {len(v['synonyms'])} entries")
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Text Eleven (last) ---")
t11 = [v for v in verses if v["text"] == 11]
if t11:
v = t11[0]
print(f" Reference: {v['reference']}")
print(f" Title: {v['title']}")
print(f" Transliteration: {v['transliteration'][:80]}...")
print(f" Devanagari: {v['devanagari'][:80]}...")
print(f" Synonyms: {len(v['synonyms'])} entries")
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
if __name__ == "__main__":
main()
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env python3
"""Extract On Chanting the Hare Kṛṣṇa Mantra (OCHKM) from other.html.
The book has 1 intro segment (front-pages) and 4 chapters/sections.
There are NO p13 bookmarks — chapters are detected via p27 headings.
Output:
lib/jsons/books/ochkm/intro.json — Book metadata
lib/jsons/books/ochkm/intro/*.json — Intro segments
lib/jsons/books/ochkm/chapters/*.json — Chapters
Usage:
python3 scripts/ochkm_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for OCHKM
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Chapter heading class (used as chapter boundary since no p13 bookmarks)
CHAPTER_HEADING_CLASS = "p27"
# Body paragraph classes (all treated as paragraphs)
BODY_CLASSES = frozenset(["p6", "p19", "p76", "p25", "p21", "p100"])
# Classes to skip entirely
SKIP_CLASSES = frozenset(["p9", "p15", "p70"])
# Chapter titles mapping (proper IAST decoded)
CHAPTER_TITLES = {
1: "Brief Biography of the Spiritual Master",
2: "On Chanting the Hare Kṛṣṇa Mantra",
3: "What is ISKCON?",
4: "Śrī Śrī Śikṣāṣṭakam",
}
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_ochkm_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the OCHKM content.
Start: <p class="p12"> containing "ON CHANTING THE HARE"
End: next <p class="p12"> after start
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = p.get_text().strip().upper()
if "ON CHANTING THE HARE" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after OCHKM start = end of OCHKM
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'ON CHANTING THE HARE KṚṢṆA MANTRA' header in other.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_chapter_headings(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all chapter headings (p27) in the OCHKM range.
Returns list of (paragraph_index, sequential_chapter_number, chapter_title).
"""
headings = []
seq = 0
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls != CHAPTER_HEADING_CLASS:
continue
text = decode_text(p)
# Remove bold/italic tags from text content
text = re.sub(r"</?[bi]>", "", text).strip()
if not text:
continue
seq += 1
# Use pre-defined proper IAST titles
ch_title = CHAPTER_TITLES.get(seq, text)
headings.append((i, seq, ch_title))
return headings
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the OCHKM content.
Detects italic+br verses in any body class → centered divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip chapter headings (p27) — they are boundaries, not content
if cls == CHAPTER_HEADING_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Body paragraphs (p6, p19, p76, p25, p21, p100)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_ochkm(soup: BeautifulSoup) -> dict:
"""Extract the entire OCHKM book 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 OCHKM boundaries
ochkm_start, ochkm_end = find_ochkm_boundaries(all_paragraphs)
print(f" OCHKM content: paragraphs {ochkm_start} to {ochkm_end}")
print(f" Total paragraphs in range: {ochkm_end - ochkm_start}")
# Step 2: Find all chapter headings (p27)
chapter_headings = find_chapter_headings(all_paragraphs, ochkm_start, ochkm_end)
print(f" Found {len(chapter_headings)} chapter headings")
for pos, ch_num, ch_title in chapter_headings:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (ochkm_start) to first chapter heading
intro_segments = []
if chapter_headings:
front_pages_end = chapter_headings[0][0]
else:
front_pages_end = ochkm_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, ochkm_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "ochkm",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_headings):
# Content starts after the heading
content_start = ch_pos + 1
# Content ends at next chapter heading or ochkm_end
if idx + 1 < len(chapter_headings):
content_end = chapter_headings[idx + 1][0]
else:
content_end = ochkm_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "ochkm",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ochkm")
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 On Chanting the Hare Kṛṣṇa Mantra...\n")
result = extract_ochkm(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "ochkm",
"title": "On Chanting the Hare Kṛṣṇa Mantra",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1967,
"description": "The sublime method for reviving our transcendental consciousness through the chanting of the holy names.",
"totalChapters": len(chapters),
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- 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 all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
if "saṅkīrtana" in all_text or "saṅkértana" in all_text or "saṁkīrtana" in all_text:
print(f" ✅ 'saṅkīrtana' variant found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ℹ No centered verse blocks found (may be expected for this book)")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+668
View File
@@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""Extract On the Way to Kṛṣṇa (1973) from other.html.
The book has 1 intro segment (front-pages) and 5 chapters.
Output:
lib/jsons/books/owk/intro.json — Book metadata
lib/jsons/books/owk/intro/*.json — Intro segments
lib/jsons/books/owk/chapters/*.json — Chapters
Usage:
python3 scripts/owk_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for OWK
# ---------------------------------------------------------------------------
# Book title header class (OWK uses p85, NOT p12)
BOOK_HEADER_CLASS = "p85"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53"])
# Bold quote paragraphs — include as body
BOLD_QUOTE_CLASSES = frozenset(["p56", "p30"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Other books listing text
OTHER_BOOKS_CLASS = "p18"
# Contents header
CONTENTS_CLASS = "p69"
# Misc text (p86 — bold quote paragraph)
MISC_CLASS = "p86"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p70"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_owk_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the OWK content.
Start: <p class="p85"> containing "On the Way" and "1973"
End: next <p class="p85"> after start (RĀJA-VIDYĀ)
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "On the Way" in text and "1973" in text:
start_idx = i
elif start_idx is not None:
# Next p85 after OWK start = end of OWK
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'On the Way to Kṛṣṇa (1973)' header in other.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 OWK chapter bookmark tags (p13) in the OWK range.
Returns list of (paragraph_index, chapter_number, 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)
m = re.match(r"OWK\s+(\d+):\s+(.+)", text.strip())
if m:
ch_num = int(m.group(1))
ch_title = m.group(2).strip()
bookmarks.append((i, ch_num, ch_title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the OWK content.
Groups consecutive p53 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip book title header (p85)
if cls == BOOK_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Contents header (p69) — include in front-pages
if cls == CONTENTS_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Other books listing (p18) — include in front-pages
if cls == OTHER_BOOKS_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Bold quote paragraphs (p56, p30) — include as body
if cls in BOLD_QUOTE_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Misc text (p86) — include as body
if cls == MISC_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_owk(soup: BeautifulSoup) -> dict:
"""Extract the entire On the Way to Kṛṣṇa (1973) 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 OWK boundaries
owk_start, owk_end = find_owk_boundaries(all_paragraphs)
print(f" OWK content: paragraphs {owk_start} to {owk_end}")
print(f" Total paragraphs in range: {owk_end - owk_start}")
# Step 2: Find all chapter bookmarks
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, owk_start, owk_end)
print(f" Found {len(chapter_bookmarks)} OWK chapter bookmarks")
for pos, ch_num, ch_title in chapter_bookmarks:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (owk_start) to first chapter bookmark (OWK 1)
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = owk_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, owk_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "owk",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or owk_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = owk_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "owk",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "owk")
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 On the Way to Kṛṣṇa (1973)...\n")
result = extract_owk(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "owk",
"title": "On the Way to Kṛṣṇa",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1973,
"description": "A guide to achieving happiness and perfection through Kṛṣṇa consciousness.",
"totalChapters": 5,
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 5 chapters present
expected_chapters = set(range(1, 6))
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 5 chapters present")
# Check all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+714
View File
@@ -0,0 +1,714 @@
#!/usr/bin/env python3
"""Extract The Perfection of Yoga (1972) from other.html.
The book has 1 intro segment (front-pages) and 11 chapters.
Chapters come from TWO bookmark prefixes:
- PoY 1–8: "The Perfection of Yoga" chapters
- TYS 4–6: "The Topmost Yoga System" bonus chapters (renumbered 9–11)
Output:
lib/jsons/books/poy/intro.json — Book metadata
lib/jsons/books/poy/intro/*.json — Intro segments
lib/jsons/books/poy/chapters/*.json — Chapters
Usage:
python3 scripts/poy_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for POY
# ---------------------------------------------------------------------------
# Book title header class (p85 for POY)
BOOK_HEADER_CLASS = "p85"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53"])
# Bold quote paragraphs — include as body
BOLD_QUOTE_CLASSES = frozenset(["p56"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Contents heading class
CONTENTS_CLASS = "p69"
# Misc classes to include as body
MISC_BODY_CLASSES = frozenset(["p90", "p91"])
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p15", "p25", "p70"])
# ---------------------------------------------------------------------------
# Chapter title mapping (ScaGoudy → proper IAST)
# ---------------------------------------------------------------------------
CHAPTER_TITLES = {
1: "Yoga as Reestablishing Relations with Kṛṣṇa",
2: "Yoga as Work in Devotion",
3: "Yoga as Meditation on Kṛṣṇa",
4: "Yoga as Rejected by Arjuna",
5: "Yoga as Body and Mind Control",
6: "Yoga as Freedom from Duality and Designation",
7: "The Fate of the Unsuccessful Yogī",
8: "The Perfection of Yoga",
9: "The Hare Kṛṣṇa Mantra",
10: "How Bhakti-yoga Works",
11: "Sources of Absolute Knowledge",
}
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_poy_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the POY content.
Start: <p class="p85"> containing "Perfection of Yoga" and "1972"
End: next <p class="p85"> after start, or end of document
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Perfection of Yoga" in text and "1972" in text:
start_idx = i
elif start_idx is not None:
# Next p85 after POY start = end of POY
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'The Perfection of Yoga (1972)' header in other.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_glossary_boundary(all_paragraphs: list, start_idx: int, end_idx: int) -> int:
"""Find the Glossary heading within POY range.
Returns the paragraph index of the Glossary heading, or end_idx if not found.
"""
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_HEADING_CLASS:
text = p.get_text().strip()
if "Glossary" in text:
return i
return end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all POY + TYS chapter bookmark tags (p13) in the POY range.
Parses both "PoY N:" and "TYS N:" patterns.
TYS 4→ch9, TYS 5→ch10, TYS 6→ch11.
Returns list of (paragraph_index, sequential_chapter_number, chapter_title).
"""
bookmarks = []
seq_counter = 0
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls != CHAPTER_BOOKMARK_CLASS:
continue
text = decode_text(p)
m = re.match(r"(?:PoY|TYS)\s+(\d+):\s+(.+)", text.strip())
if m:
seq_counter += 1
ch_title_raw = m.group(2).strip()
# Use our pre-defined proper IAST titles
ch_title = CHAPTER_TITLES.get(seq_counter, ch_title_raw)
bookmarks.append((i, seq_counter, ch_title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the POY content.
Groups consecutive p53 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip book title header (p85)
if cls == BOOK_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Contents heading (p69) — include in front-pages
if cls == CONTENTS_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Bold quote paragraphs (p56) — include as body
if cls in BOLD_QUOTE_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Misc body classes (p90, p91)
if cls in MISC_BODY_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_poy(soup: BeautifulSoup) -> dict:
"""Extract the entire Perfection of Yoga (1972) 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 POY boundaries
poy_start, poy_end = find_poy_boundaries(all_paragraphs)
print(f" POY content: paragraphs {poy_start} to {poy_end}")
print(f" Total paragraphs in range: {poy_end - poy_start}")
# Step 2: Find Glossary boundary — everything from Glossary onward is excluded
glossary_idx = find_glossary_boundary(all_paragraphs, poy_start, poy_end)
if glossary_idx < poy_end:
print(f" Glossary found at paragraph {glossary_idx} — trimming end boundary")
poy_end = glossary_idx
else:
print(f" No Glossary heading found — using full range")
print(f" Effective content range: paragraphs {poy_start} to {poy_end}")
# Step 3: Find all chapter bookmarks (PoY 1-8, TYS 4-6)
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, poy_start, poy_end)
print(f" Found {len(chapter_bookmarks)} chapter bookmarks")
for pos, ch_num, ch_title in chapter_bookmarks:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 4: Extract front pages
# From book start (poy_start) to first chapter bookmark
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = poy_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, poy_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "poy",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 5: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or poy_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = poy_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "poy",
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "poy")
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 The Perfection of Yoga (1972)...\n")
result = extract_poy(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "poy",
"title": "The Perfection of Yoga",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1972,
"description": "The complete science of yoga as taught by Lord Kṛṣṇa in the Bhagavad-gītā.",
"totalChapters": 11,
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 11 chapters present
expected_chapters = set(range(1, 12))
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 11 chapters present")
# Check all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+848
View File
@@ -0,0 +1,848 @@
#!/usr/bin/env python3
"""Extract Perfect Questions Perfect Answers (1977) from other.html.
The book has 3 intro segments (front-pages, introduction, about-the-author),
9 chapters, and a Concluding Words section.
Output:
lib/jsons/books/pqpa/intro.json — Book metadata
lib/jsons/books/pqpa/intro/*.json — Intro segments
lib/jsons/books/pqpa/chapters/*.json — Chapters + concluding words
Usage:
python3 scripts/pqpa_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, 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 with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for PQPA
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Bookmark class — used for section boundary detection
BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6", "p48"])
# Dialogue line class — the main content class (714 occurrences)
DIALOGUE_CLASS = "p92"
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53", "p66"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Section heading (bold)
SECTION_HEADING_CLASS = "p31"
# Front pages text
FRONT_PAGES_CLASS = "p18"
# Attribution text
ATTRIBUTION_CLASS = "p42"
# Numbered items / verse quotes
NUMBERED_CLASS = "p24"
# Mantra text
MANTRA_CLASS = "p19"
# Contents heading
CONTENTS_CLASS = "p69"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Misc classes to include
MISC_CLASSES = frozenset(["p28", "p76", "p93", "p94", "p95", "p96"])
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p15", "p70"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_pqpa_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the PQPA content.
Start: <p class="p12"> containing "Perfect Questions Perfect Answers"
End: next <p class="p12"> after start, or end of file
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "Perfect Questions Perfect Answers" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after PQPA start = end of PQPA
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Perfect Questions Perfect Answers' header in other.html!"
)
sys.exit(1)
# Also check for Glossary marker as end boundary
for i in range(start_idx + 1, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == TITLE_PAGE_CLASS:
text = p.get_text().strip()
if text == "Glossary":
end_idx = i
break
return start_idx, end_idx
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all p13 bookmark tags in the PQPA range.
Returns list of (paragraph_index, bookmark_text).
The bookmark text is stripped of any span noise like <span class="s27"> </span>.
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == BOOKMARK_CLASS:
# Get text, stripping span noise
text = decode_text(p).strip()
if text.startswith("PQPA"):
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str):
"""Parse a chapter bookmark like 'PQPA 1: Kṛṣṇa, the All-Attractive'.
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"PQPA\s+(\d+):\s+(.+)", text.strip())
if m:
return int(m.group(1)), m.group(2).strip()
return None
def parse_intro_bookmark(text: str) -> bool:
"""Check if this is the Introduction bookmark."""
return text.strip() == "PQPA Introduction"
def parse_concluding_bookmark(text: str) -> bool:
"""Check if this is the Concluding Words bookmark."""
return text.strip() == "PQPA Concluding Words"
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter, intro segment, or conclusion).
Handles all CSS classes within the PQPA content.
Groups consecutive p53/p66 verse pādas into centered italic divs.
Detects verse patterns in p6/p92 (starts with <i>, has <br>).
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == BOOKMARK_CLASS:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53, p66) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Dialogue lines (p92) — the main content class
if cls == DIALOGUE_CLASS:
html = decode_html_content(p)
if html:
# Detect verses disguised as dialogue: starts with <i> and has <br>
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Section heading (p31) — include as paragraph
if cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Front pages text (p18) — include
if cls == FRONT_PAGES_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Contents heading (p69) — include
if cls == CONTENTS_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Attribution text (p42)
if cls == ATTRIBUTION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Numbered items / verse quotes (p24)
if cls == NUMBERED_CLASS:
html = decode_html_content(p)
if html:
# Detect verses: starts with <i> and has <br>
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Mantra text (p19) — wrap in centered div
if cls == MANTRA_CLASS:
html = decode_html_content(p)
if html:
# Check if it's a multi-line verse
if "<br" in html:
inner = re.sub(r"</?i>", "", html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6, p48)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Misc classes — include as-is
if cls in MISC_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_pqpa(soup: BeautifulSoup) -> dict:
"""Extract the entire Perfect Questions Perfect Answers from the parsed HTML.
Returns a dict with:
"intro_segments": list of intro segment dicts
"chapters": list of chapter dicts
"concluding_words": concluding words dict
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find PQPA boundaries
pqpa_start, pqpa_end = find_pqpa_boundaries(all_paragraphs)
print(f" PQPA content: paragraphs {pqpa_start} to {pqpa_end}")
print(f" Total paragraphs in range: {pqpa_end - pqpa_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, pqpa_start, pqpa_end)
print(f" Found {len(bookmarks)} PQPA bookmarks")
for pos, text in bookmarks:
print(f" Line ~{pos}: {text}")
# Step 3: Categorize bookmarks
intro_bookmark = None # (para_idx,)
chapter_bookmarks = [] # (para_idx, chapter_num, title)
concluding_bookmark = None # (para_idx,)
for pos, text in bookmarks:
ch = parse_chapter_bookmark(text)
if ch:
chapter_bookmarks.append((pos, ch[0], ch[1]))
elif parse_intro_bookmark(text):
intro_bookmark = pos
elif parse_concluding_bookmark(text):
concluding_bookmark = pos
print(
f" Introduction bookmark: {'found' if intro_bookmark is not None else 'NOT FOUND'}"
)
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
print(
f" Concluding Words bookmark: {'found' if concluding_bookmark is not None else 'NOT FOUND'}"
)
# Collect all section starts for boundary computation
all_section_starts = sorted(
([intro_bookmark] if intro_bookmark is not None else [])
+ [pos for pos, _, _ in chapter_bookmarks]
+ ([concluding_bookmark] if concluding_bookmark is not None else [])
)
# Step 4: Extract front-pages (from p12 header to Introduction bookmark)
front_pages_start = pqpa_start + 1 # after p12 header
front_pages_end = (
intro_bookmark
if intro_bookmark is not None
else (chapter_bookmarks[0][0] if chapter_bookmarks else pqpa_end)
)
front_pages_paras = extract_paragraphs(
all_paragraphs, front_pages_start, front_pages_end, is_front_pages=True
)
front_pages = {
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "pqpa",
"order": 1,
"paragraphs": front_pages_paras,
}
# Step 5: Extract Introduction (from PQPA Introduction to PQPA 1)
introduction = None
if intro_bookmark is not None:
intro_content_start = intro_bookmark + 1
# End at the first chapter bookmark
intro_content_end = chapter_bookmarks[0][0] if chapter_bookmarks else pqpa_end
intro_paras = extract_paragraphs(
all_paragraphs, intro_content_start, intro_content_end
)
introduction = {
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "pqpa",
"order": 2,
"paragraphs": intro_paras,
}
# Step 6: Extract chapters 1-9
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark, or concluding words, or pqpa_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
elif concluding_bookmark is not None:
content_end = concluding_bookmark
else:
content_end = pqpa_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "pqpa",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 7: Extract Concluding Words + About the Author
concluding_words = None
about_the_author = None
if concluding_bookmark is not None:
# Find the "About the Author" boundary:
# <p class="p27"><b>His Divine Grace A. C. Bhaktivedanta Swami Prabhupāda</b></p>
about_author_pos = None
for i in range(concluding_bookmark + 1, pqpa_end):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_HEADING_CLASS:
text = decode_text(p)
if "His Divine Grace" in text:
about_author_pos = i
break
# Concluding Words: from bookmark to About the Author (or pqpa_end)
cw_content_start = concluding_bookmark + 1
cw_content_end = about_author_pos if about_author_pos is not None else pqpa_end
cw_paras = extract_paragraphs(all_paragraphs, cw_content_start, cw_content_end)
concluding_words = {
"book": "pqpa",
"chapter": None,
"title": "Concluding Words",
"type": "conclusion",
"slug": "concluding-words",
"paragraphs": cw_paras,
}
# About the Author: from p27 heading to pqpa_end
if about_author_pos is not None:
ata_paras = extract_paragraphs(all_paragraphs, about_author_pos, pqpa_end)
about_the_author = {
"slug": "about-the-author",
"title": "About the Author",
"type": "intro-segment",
"book": "pqpa",
"order": 99,
"paragraphs": ata_paras,
}
# Step 8: Build intro segments list
intro_segments = [front_pages]
if introduction:
intro_segments.append(introduction)
if about_the_author:
intro_segments.append(about_the_author)
# Step 9: 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,
"concluding_words": concluding_words,
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "pqpa")
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 Perfect Questions Perfect Answers (1977)...\n")
result = extract_pqpa(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
concluding_words = result["concluding_words"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "pqpa",
"title": "Perfect Questions Perfect Answers",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1977,
"description": "Conversations between Śrīla Prabhupāda and Bob Cohen, a Peace Corps worker in India.",
"totalChapters": 9,
"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']}"
)
# --- Write concluding words ---
if concluding_words:
filepath = os.path.join(output_base, "chapters", "concluding-words.json")
write_json(concluding_words, filepath)
print(
f"\n Concluding Words: {len(concluding_words['paragraphs'])} paragraphs → {filepath}"
)
# --- 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_concluding_paras = (
len(concluding_words["paragraphs"]) if concluding_words else 0
)
total_paras = total_intro_paras + total_chapter_paras + total_concluding_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)}")
print(f" Concluding Words: {'Yes' if concluding_words else 'No'}")
print(f" Total paragraphs: {total_paras}")
print(f" Intro: {total_intro_paras}")
print(f" Chapters: {total_chapter_paras}")
print(f" Concluding: {total_concluding_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: Chapter 1 ---")
if chapters:
ch1 = chapters[0]
for i, p in enumerate(ch1["paragraphs"][:4]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 9:
print(f"\n--- Sample: Chapter 9 (last) ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if concluding_words:
print(f"\n--- Sample: Concluding Words ---")
for i, p in enumerate(concluding_words["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 9 chapters present
expected_chapters = set(range(1, 10))
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 9 chapters present")
# Check intro segments
expected_intros = {"front-pages", "introduction", "about-the-author"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
else:
print(f" ✅ All 3 intro segments present")
# Check concluding words
if concluding_words:
print(f" ✅ Concluding Words present")
else:
print(f" ⚠ Concluding Words NOT found")
# 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 — look for common encoded characters
encoded_chars = set("äåöëòçüéàùè")
suspect_count = 0
all_sections = chapters + intro_segments
if concluding_words:
all_sections = all_sections + [concluding_words]
for section in all_sections:
for p in section["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
for c in encoded_chars:
if c in plain:
suspect_count += 1
break
if suspect_count == 0:
print(f" ✅ No suspected undecoded ScaGoudy characters found")
else:
print(
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
)
# Check verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
print(f" ℹ {verse_block_count} verse blocks in chapters")
# ScaGoudy decoding spot check
print(f"\n--- ScaGoudy Decoding Spot Check ---")
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"][:10])
if "Kṛṣṇa" in ch1_text:
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
elif "Kåñëa" in ch1_text:
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
else:
print(
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 10 paragraphs (may be normal)"
)
# Check for "Śrīla Prabhupāda"
if "Prabhupāda" in ch1_text:
print(f" ✅ 'Prabhupāda' correctly decoded")
elif "Prabhupäda" in ch1_text:
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
# Dialogue line check — look for BOB: and ŚRĪLA PRABHUPĀDA: speakers
print(f"\n--- Dialogue Speaker Check ---")
speaker_counts = {}
for ch in chapters:
for p in ch["paragraphs"]:
# Look for bold speaker patterns
m = re.match(r"<b>([^<]+):</b>", p)
if m:
speaker = m.group(1).strip()
speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1
if speaker_counts:
print(f" Speakers found:")
for speaker, count in sorted(speaker_counts.items(), key=lambda x: -x[1]):
print(f" {speaker}: {count} lines")
else:
print(f" ⚠ No dialogue speakers detected")
# Sample verse blocks
print(f"\n--- Sample Verse Blocks ---")
shown = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
shown += 1
if shown >= 3:
break
if shown >= 3:
break
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""Extract Rāja-vidyā: The King of Knowledge (1973) from other.html.
The book has 1 intro segment (front-pages) and 8 chapters.
Output:
lib/jsons/books/rv/intro.json — Book metadata
lib/jsons/books/rv/intro/*.json — Intro segments
lib/jsons/books/rv/chapters/*.json — Chapters
Usage:
python3 scripts/rv_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for RV
# ---------------------------------------------------------------------------
# Book title header class (multi-line with <br>)
BOOK_HEADER_CLASS = "p85"
# Chapter bookmark class
CHAPTER_BOOKMARK_CLASS = "p13"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p53"])
# Verse translations / body text
TRANSLATION_CLASS = "p44"
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Title page text
TITLE_PAGE_CLASS = "p3"
# Contents heading classes
CONTENTS_CLASSES = frozenset(["p87", "p88"])
# Back-cover blurb class
BACK_COVER_CLASS = "p89"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p70", "p15"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_rv_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the RV content.
Start: <p class="p85"> containing "King of Knowledge" and "1973"
End: next <p class="p85"> after start, or <p class="p89"> back-cover text
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = decode_text(p)
if "King of Knowledge" in text and "1973" in text:
start_idx = i
elif start_idx is not None:
# Next p85 after RV start = end of RV
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'King of Knowledge (1973)' header in other.html!")
sys.exit(1)
# Also check for p89 back-cover text as potential end boundary
# The p89 tag appears right before the next p85 and is part of RV
# so we keep end_idx at the next p85 (or end of file)
return start_idx, end_idx
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all RV chapter bookmark tags (p13) in the RV range.
Returns list of (paragraph_index, chapter_number, 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)
m = re.match(r"RV\s+(\d+):\s+(.+)", text.strip())
if m:
ch_num = int(m.group(1))
ch_title = m.group(2).strip()
bookmarks.append((i, ch_num, ch_title))
return bookmarks
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the RV content.
Groups consecutive p53 verse pādas into centered italic divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags (p13)
if cls == CHAPTER_BOOKMARK_CLASS:
continue
# Skip book title header (p85)
if cls == BOOK_HEADER_CLASS:
continue
# Skip back-cover blurb (p89)
if cls == BACK_COVER_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p53) — accumulate into centered block
if cls in VERSE_PADA_CLASSES:
html = decode_html_content(p)
if html:
# Remove inner <i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
verse_accumulator.append(stripped)
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Chapter heading (p27) — include as paragraph
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page text (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Contents heading classes (p87, p88) — include in front-pages
if cls in CONTENTS_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translation / body text (p44)
if cls == TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_rv(soup: BeautifulSoup) -> dict:
"""Extract the entire Rāja-vidyā: The King of Knowledge (1973) 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 RV boundaries
rv_start, rv_end = find_rv_boundaries(all_paragraphs)
print(f" RV content: paragraphs {rv_start} to {rv_end}")
print(f" Total paragraphs in range: {rv_end - rv_start}")
# Step 2: Find all chapter bookmarks
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, rv_start, rv_end)
print(f" Found {len(chapter_bookmarks)} RV chapter bookmarks")
for pos, ch_num, ch_title in chapter_bookmarks:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (rv_start) to first chapter bookmark (RV 1)
intro_segments = []
if chapter_bookmarks:
front_pages_end = chapter_bookmarks[0][0]
else:
front_pages_end = rv_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, rv_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "rv",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or rv_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = rv_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "rv",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "rv")
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āja-vidyā: The King of Knowledge (1973)...\n")
result = extract_rv(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "rv",
"title": "Rāja-vidyā: The King of Knowledge",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1973,
"description": "The king of knowledge — the most confidential wisdom of the Bhagavad-gītā.",
"totalChapters": 8,
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 8 chapters present
expected_chapters = set(range(1, 9))
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 8 chapters present")
# Check all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
# Check for properly decoded words
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Bhagavad-gītā" in all_text:
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
elif "gītā" in all_text or "Gītā" in all_text:
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants (common encoded chars)
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
break
else:
continue
break
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-bbd.ts — Push Beyond Birth and Death JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-ek.ts) to avoid
* Prisma replica-set write requirements.
*
* BBD is a prose book (like EK) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "bbd", data: <intro.json> }
* - prose_chapters → 1 intro segment + 5 chapters = 6 docs
*
* Usage:
* npx tsx scripts/seed-bbd.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 BBD_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "bbd");
/** 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 BbdIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type BbdIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type BbdChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedBbd(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(BBD_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ BBD intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<BbdIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "bbd" });
await metaCol.insertOne({ book: "bbd", data: intro });
console.log(" 📖 BBD book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "bbd" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(BBD_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<BbdIntroSegmentJson>(filePath);
docs.push({
book: "bbd",
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 05.json)
const chaptersDir = path.join(BBD_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<BbdChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "bbd",
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: "bbd" });
console.log(` ✅ BBD: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Beyond Birth and Death...\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 seedBbd(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);
});
+237
View File
@@ -0,0 +1,237 @@
/**
* seed-ds.ts — Push Dialectic Spiritualism JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-ssr.ts) to avoid
* Prisma replica-set write requirements.
*
* DS is a large prose/dialogue book with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Sub-chapters are stored with slugs like "1-1", "1-2", "2-1", etc.
* Each sub-chapter JSON has parentChapter and subChapter fields used to
* compute a stable ordering: parentChapter * 100 + subChapter.
*
* Collections written:
* - book_meta → one doc { book: "ds", data: <intro.json> }
* - prose_chapters → 3 intro segments + 38 sub-chapters
*
* Usage:
* npx tsx scripts/seed-ds.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 DS_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ds");
/** 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 DsIntroJson = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: {
number: number;
title: string;
subChapters: { slug: string; title: string }[];
}[];
};
type DsIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type DsChapterJson = {
book: string;
chapter: string; // "1-1", "2-3", etc.
parentChapter: number;
subChapter: number;
title: string;
parentTitle: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedDs(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(DS_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ DS intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<DsIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "ds" });
await metaCol.insertOne({ book: "ds", data: intro });
console.log(" 📖 DS book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "ds" });
const docs: Record<string, unknown>[] = [];
// Intro segments (3 total: front-pages, foreword, preface)
const introSegmentFiles = [
"front-pages.json",
"foreword.json",
"preface.json",
];
const introDir = path.join(DS_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<DsIntroSegmentJson>(filePath);
docs.push({
book: "ds",
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}`);
// Sub-chapters (1-1.json, 1-2.json, 2-1.json, etc.)
const chaptersDir = path.join(DS_DIR, "chapters");
let chapterCount = 0;
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort((a, b) => {
// Sort by parentChapter then subChapter numerically
const parseSlug = (name: string) => {
const slug = name.replace(".json", "");
const parts = slug.split("-");
return {
parent: parseInt(parts[0], 10),
sub: parseInt(parts[1], 10),
};
};
const aa = parseSlug(a);
const bb = parseSlug(b);
if (aa.parent !== bb.parent) return aa.parent - bb.parent;
return aa.sub - bb.sub;
});
for (const file of chapterFiles) {
const ch = readJson<DsChapterJson>(path.join(chaptersDir, file));
const order = ch.parentChapter * 100 + ch.subChapter;
docs.push({
book: "ds",
volume: NO_VOLUME,
chapter: null, // null since chapter is a string "1-1" not an int
title: ch.title,
type: "chapter",
slug: ch.chapter, // "1-1", "1-2", "2-1", etc.
order,
paragraphs: ch.paragraphs,
});
chapterCount++;
}
console.log(` 📖 Sub-chapters: ${chapterCount}`);
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "ds" });
console.log(` ✅ DS: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Dialectic Spiritualism...\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 seedDs(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);
});
+208
View File
@@ -0,0 +1,208 @@
/**
* seed-ej.ts — Push Easy Journey to Other Planets JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
* Prisma replica-set write requirements.
*
* EJ is a prose book (like NoD/TLC) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "ej", data: <intro.json> }
* - prose_chapters → 2 intro segments + 2 chapters = 4 docs
*
* Usage:
* npx tsx scripts/seed-ej.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 EJ_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ej");
/** 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 EjIntroJson = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type EjIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type EjChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedEj(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(EJ_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ EJ intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<EjIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "ej" });
await metaCol.insertOne({ book: "ej", data: intro });
console.log(" 📖 EJ book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "ej" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = [
"front-pages.json",
"preface.json",
];
const introDir = path.join(EJ_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<EjIntroSegmentJson>(filePath);
docs.push({
book: "ej",
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, 02.json)
const chaptersDir = path.join(EJ_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<EjChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "ej",
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: "ej" });
console.log(` ✅ EJ: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Easy Journey to Other Planets...\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 seedEj(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-ek.ts — Push Elevation to Kṛṣṇa Consciousness JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-ej.ts) to avoid
* Prisma replica-set write requirements.
*
* EK is a prose book (like EJ) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "ek", data: <intro.json> }
* - prose_chapters → 1 intro segment + 6 chapters = 7 docs
*
* Usage:
* npx tsx scripts/seed-ek.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 EK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ek");
/** 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 EkIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type EkIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type EkChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedEk(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(EK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ EK intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<EkIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "ek" });
await metaCol.insertOne({ book: "ek", data: intro });
console.log(" 📖 EK book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "ek" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(EK_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<EkIntroSegmentJson>(filePath);
docs.push({
book: "ek",
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 06.json)
const chaptersDir = path.join(EK_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<EkChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "ek",
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: "ek" });
console.log(` ✅ EK: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Elevation to Kṛṣṇa Consciousness...\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 seedEk(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-hkh.ts — Push The Kṛṣṇa Consciousness Handbook JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-owk.ts) to avoid
* Prisma replica-set write requirements.
*
* HKH 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: "hkh", data: <intro.json> }
* - prose_chapters → 2 intro segments + 7 chapters = 9 docs
*
* Usage:
* npx tsx scripts/seed-hkh.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 HKH_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "hkh");
/** 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 HkhIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type HkhIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type HkhChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedHkh(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(HKH_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ HKH intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<HkhIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "hkh" });
await metaCol.insertOne({ book: "hkh", data: intro });
console.log(" 📖 HKH book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "hkh" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json", "introduction.json"];
const introDir = path.join(HKH_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<HkhIntroSegmentJson>(filePath);
docs.push({
book: "hkh",
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 07.json)
const chaptersDir = path.join(HKH_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<HkhChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "hkh",
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: "hkh" });
console.log(` ✅ HKH: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding The Kṛṣṇa Consciousness Handbook...\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 seedHkh(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);
});
+235
View File
@@ -0,0 +1,235 @@
/**
* seed-iso.ts — Push Śrī Īśopaniṣad JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
* Prisma replica-set write requirements.
*
* ISO is a verse book (like BG) but also has prose intro segments.
* We use volume=0 as the sentinel for "no volumes" on the prose side.
*
* Collections written:
* - book_meta → one doc { book: "iso", data: <intro.json> }
* - verses → 19 verse docs (Invocation + Mantras 1-18)
* - prose_chapters → 3 intro segment docs (front-pages, introduction, about-the-author)
*
* Usage:
* npx tsx scripts/seed-iso.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 ISO_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "iso");
/** 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 IsoIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalMantras: number;
mantras: { number: number; title: string; slug: string }[];
};
type IsoVerseJson = {
book: string;
book_title: string;
mantra: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
type IsoIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedIso(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(ISO_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ ISO intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<IsoIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "iso" });
await metaCol.insertOne({ book: "iso", data: intro });
console.log(" 📖 ISO book meta inserted");
// ---- 2. Verses ----
const versesCol = db.collection("verses");
await versesCol.deleteMany({ book: "iso" });
const verseDocs: Record<string, unknown>[] = [];
// Verse files: invocation.json, 01.json through 18.json
const verseFiles = [
"invocation.json",
...Array.from({ length: 18 }, (_, i) =>
String(i + 1).padStart(2, "0") + ".json"
),
];
const versesDir = path.join(ISO_DIR, "verses");
for (const file of verseFiles) {
const filePath = path.join(versesDir, file);
if (!fs.existsSync(filePath)) {
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
continue;
}
const v = readJson<IsoVerseJson>(filePath);
verseDocs.push({
book: "iso",
division: null,
chapter: v.mantra,
verse: 1,
verseEnd: null,
reference: v.reference,
bookTitle: v.book_title,
devanagari: v.devanagari,
bengali: null,
transliteration: v.transliteration,
synonyms: v.synonyms,
translation: v.translation,
purport: v.purport,
});
}
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
console.log(` Inserting ${verseDocs.length} verse documents...`);
await insertBatched(versesCol, verseDocs);
const totalVerses = await versesCol.countDocuments({ book: "iso" });
console.log(` ✅ ISO verses: ${totalVerses} in MongoDB`);
// ---- 3. Prose intro segments ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "iso" });
const proseDocs: Record<string, unknown>[] = [];
const introSegmentFiles = [
"front-pages.json",
"introduction.json",
"about-the-author.json",
];
const introDir = path.join(ISO_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<IsoIntroSegmentJson>(filePath);
proseDocs.push({
book: "iso",
volume: NO_VOLUME,
chapter: null,
title: seg.title,
type: seg.type || "intro-segment",
slug: `intro-${seg.slug}`,
order: seg.order,
paragraphs: seg.paragraphs,
});
}
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
console.log(` Inserting ${proseDocs.length} prose documents...`);
await insertBatched(proseCol, proseDocs);
const totalProse = await proseCol.countDocuments({ book: "iso" });
console.log(` ✅ ISO prose: ${totalProse} entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Śrī Īśopaniṣad...\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 seedIso(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-mg.ts — Push Kṛṣṇa Consciousness: The Matchless Gift JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-ek.ts) to avoid
* Prisma replica-set write requirements.
*
* MG is a prose book (like EK) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "mg", data: <intro.json> }
* - prose_chapters → 1 intro segment + 7 chapters = 8 docs
*
* Usage:
* npx tsx scripts/seed-mg.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 MG_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "mg");
/** 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 MgIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type MgIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type MgChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedMg(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(MG_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ MG intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<MgIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "mg" });
await metaCol.insertOne({ book: "mg", data: intro });
console.log(" 📖 MG book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "mg" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(MG_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<MgIntroSegmentJson>(filePath);
docs.push({
book: "mg",
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 07.json)
const chaptersDir = path.join(MG_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<MgChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "mg",
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: "mg" });
console.log(` ✅ MG: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Kṛṣṇa Consciousness: The Matchless Gift...\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 seedMg(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);
});
+222
View File
@@ -0,0 +1,222 @@
/**
* seed-nod.ts — Push The Nectar of Devotion JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-db.ts) to avoid
* Prisma replica-set write requirements.
*
* NoD is a prose book (like KB) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "nod", data: <intro.json> }
* - prose_chapters → intro segments + 51 chapters + concluding words
*
* Usage:
* npx tsx scripts/seed-nod.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 NOD_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "nod");
/** 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 NodIntroJson = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: { number: number; title: string; chapters: number[] }[];
chapters: { number: number; title: string }[];
};
type NodIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type NodChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedNod(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(NOD_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ NoD intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<NodIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "nod" });
await metaCol.insertOne({ book: "nod", data: intro });
console.log(" 📖 NoD book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "nod" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introDir = path.join(NOD_DIR, "intro");
if (fs.existsSync(introDir)) {
const introFiles = fs
.readdirSync(introDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of introFiles) {
const seg = readJson<NodIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "nod",
volume: NO_VOLUME,
chapter: null,
title: seg.title,
type: seg.type || "intro-segment",
slug: `intro-${seg.slug || file.replace(".json", "")}`,
order: seg.order ?? 0,
paragraphs: seg.paragraphs,
});
}
console.log(` 📄 Intro segments: ${introFiles.length}`);
}
// Chapters (01.json – 51.json)
const chaptersDir = path.join(NOD_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json") && f !== "concluding-words.json")
.sort();
for (const file of chapterFiles) {
const ch = readJson<NodChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "nod",
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: ch.type || "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📖 Chapters: ${chapterFiles.length}`);
// Concluding words
const concludingPath = path.join(chaptersDir, "concluding-words.json");
if (fs.existsSync(concludingPath)) {
const cw = readJson<NodChapterJson>(concludingPath);
docs.push({
book: "nod",
volume: NO_VOLUME,
chapter: null,
title: cw.title,
type: cw.type || "conclusion",
slug: "concluding-words",
order: 9999,
paragraphs: cw.paragraphs,
});
console.log(" 📝 Concluding words: 1");
}
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "nod" });
console.log(` ✅ NoD: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding The Nectar of Devotion...\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 seedNod(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);
});
+233
View File
@@ -0,0 +1,233 @@
/**
* seed-noi.ts — Push The Nectar of Instruction JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
* Prisma replica-set write requirements.
*
* NoI is a verse book (like ISO) with prose intro segments.
* We use volume=0 as the sentinel for "no volumes" on the prose side.
*
* Collections written:
* - book_meta → one doc { book: "noi", data: <intro.json> }
* - verses → 11 verse docs (Texts 1-11)
* - prose_chapters → 3 intro segment docs (front-pages, preface, appendixes)
*
* Usage:
* npx tsx scripts/seed-noi.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 NOI_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "noi");
/** 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 NoiIntroJson = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: { number: number; title: string; slug: string }[];
};
type NoiVerseJson = {
book: string;
book_title: string;
text: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
type NoiIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedNoi(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(NOI_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ NoI intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<NoiIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "noi" });
await metaCol.insertOne({ book: "noi", data: intro });
console.log(" 📖 NoI book meta inserted");
// ---- 2. Verses ----
const versesCol = db.collection("verses");
await versesCol.deleteMany({ book: "noi" });
const verseDocs: Record<string, unknown>[] = [];
// Verse files: 01.json through 11.json
const verseFiles = Array.from({ length: 11 }, (_, i) =>
String(i + 1).padStart(2, "0") + ".json"
);
const versesDir = path.join(NOI_DIR, "verses");
for (const file of verseFiles) {
const filePath = path.join(versesDir, file);
if (!fs.existsSync(filePath)) {
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
continue;
}
const v = readJson<NoiVerseJson>(filePath);
verseDocs.push({
book: "noi",
division: null,
chapter: v.text,
verse: 1,
verseEnd: null,
reference: v.reference,
bookTitle: v.book_title,
devanagari: v.devanagari,
bengali: null,
transliteration: v.transliteration,
synonyms: v.synonyms,
translation: v.translation,
purport: v.purport,
});
}
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
console.log(` Inserting ${verseDocs.length} verse documents...`);
await insertBatched(versesCol, verseDocs);
const totalVerses = await versesCol.countDocuments({ book: "noi" });
console.log(` ✅ NoI verses: ${totalVerses} in MongoDB`);
// ---- 3. Prose intro segments ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "noi" });
const proseDocs: Record<string, unknown>[] = [];
const introSegmentFiles = [
"front-pages.json",
"preface.json",
"appendixes.json",
];
const introDir = path.join(NOI_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<NoiIntroSegmentJson>(filePath);
proseDocs.push({
book: "noi",
volume: NO_VOLUME,
chapter: null,
title: seg.title,
type: seg.type || "intro-segment",
slug: `intro-${seg.slug}`,
order: seg.order,
paragraphs: seg.paragraphs,
});
}
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
console.log(` Inserting ${proseDocs.length} prose documents...`);
await insertBatched(proseCol, proseDocs);
const totalProse = await proseCol.countDocuments({ book: "noi" });
console.log(` ✅ NoI prose: ${totalProse} entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding The Nectar of Instruction...\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 seedNoi(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-ochkm.ts — Push On Chanting the Hare Kṛṣṇa Mantra JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-poy.ts) to avoid
* Prisma replica-set write requirements.
*
* OCHKM is a prose book (like POY) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "ochkm", data: <intro.json> }
* - prose_chapters → 1 intro segment + 4 chapters = 5 docs
*
* Usage:
* npx tsx scripts/seed-ochkm.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 OCHKM_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ochkm");
/** 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 OchkmIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type OchkmIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type OchkmChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedOchkm(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(OCHKM_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ OCHKM intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<OchkmIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "ochkm" });
await metaCol.insertOne({ book: "ochkm", data: intro });
console.log(" 📖 OCHKM book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "ochkm" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(OCHKM_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<OchkmIntroSegmentJson>(filePath);
docs.push({
book: "ochkm",
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 04.json)
const chaptersDir = path.join(OCHKM_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<OchkmChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "ochkm",
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: "ochkm" });
console.log(` ✅ OCHKM: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding On Chanting the Hare Kṛṣṇa Mantra...\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 seedOchkm(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-owk.ts — Push On the Way to Kṛṣṇa JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-bbd.ts) to avoid
* Prisma replica-set write requirements.
*
* OWK is a prose book (like BBD) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "owk", data: <intro.json> }
* - prose_chapters → 1 intro segment + 5 chapters = 6 docs
*
* Usage:
* npx tsx scripts/seed-owk.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 OWK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "owk");
/** 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 OwkIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type OwkIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type OwkChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedOwk(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(OWK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ OWK intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<OwkIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "owk" });
await metaCol.insertOne({ book: "owk", data: intro });
console.log(" 📖 OWK book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "owk" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(OWK_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<OwkIntroSegmentJson>(filePath);
docs.push({
book: "owk",
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 05.json)
const chaptersDir = path.join(OWK_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<OwkChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "owk",
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: "owk" });
console.log(` ✅ OWK: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding On the Way to Kṛṣṇa...\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 seedOwk(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-poy.ts — Push The Perfection of Yoga JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-bbd.ts) to avoid
* Prisma replica-set write requirements.
*
* POY is a prose book (like BBD) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "poy", data: <intro.json> }
* - prose_chapters → 1 intro segment + 11 chapters = 12 docs
*
* Usage:
* npx tsx scripts/seed-poy.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 POY_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "poy");
/** 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 PoyIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type PoyIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type PoyChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedPoy(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(POY_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ POY intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<PoyIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "poy" });
await metaCol.insertOne({ book: "poy", data: intro });
console.log(" 📖 POY book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "poy" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(POY_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<PoyIntroSegmentJson>(filePath);
docs.push({
book: "poy",
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 11.json)
const chaptersDir = path.join(POY_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<PoyChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "poy",
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: "poy" });
console.log(` ✅ POY: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding The Perfection of Yoga...\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 seedPoy(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);
});
+220
View File
@@ -0,0 +1,220 @@
/**
* seed-pqpa.ts — Push Perfect Questions Perfect Answers JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
* Prisma replica-set write requirements.
*
* PQPA is a prose/dialogue book with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "pqpa", data: <intro.json> }
* - prose_chapters → intro segments + 9 chapters + concluding words
*
* Usage:
* npx tsx scripts/seed-pqpa.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 PQPA_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "pqpa");
/** 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 PqpaIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type PqpaIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type PqpaChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedPqpa(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(PQPA_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ PQPA intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<PqpaIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "pqpa" });
await metaCol.insertOne({ book: "pqpa", data: intro });
console.log(" 📖 PQPA book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "pqpa" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introDir = path.join(PQPA_DIR, "intro");
if (fs.existsSync(introDir)) {
const introFiles = fs
.readdirSync(introDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of introFiles) {
const seg = readJson<PqpaIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "pqpa",
volume: NO_VOLUME,
chapter: null,
title: seg.title,
type: seg.type || "intro-segment",
slug: `intro-${seg.slug || file.replace(".json", "")}`,
order: seg.order ?? 0,
paragraphs: seg.paragraphs,
});
}
console.log(` 📄 Intro segments: ${introFiles.length}`);
}
// Chapters (01.json – 09.json)
const chaptersDir = path.join(PQPA_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json") && f !== "concluding-words.json")
.sort();
for (const file of chapterFiles) {
const ch = readJson<PqpaChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "pqpa",
volume: NO_VOLUME,
chapter: chNum,
title: ch.title,
type: ch.type || "chapter",
slug: String(chNum),
order: 100 + chNum,
paragraphs: ch.paragraphs,
});
}
console.log(` 📖 Chapters: ${chapterFiles.length}`);
// Concluding words
const concludingPath = path.join(chaptersDir, "concluding-words.json");
if (fs.existsSync(concludingPath)) {
const cw = readJson<PqpaChapterJson>(concludingPath);
docs.push({
book: "pqpa",
volume: NO_VOLUME,
chapter: null,
title: cw.title,
type: cw.type || "conclusion",
slug: "concluding-words",
order: 9999,
paragraphs: cw.paragraphs,
});
console.log(" 📝 Concluding words: 1");
}
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "pqpa" });
console.log(` ✅ PQPA: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Perfect Questions Perfect Answers...\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 seedPqpa(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-rv.ts — Push Rāja-vidyā: The King of Knowledge JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-bbd.ts) to avoid
* Prisma replica-set write requirements.
*
* RV is a prose book (like BBD) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "rv", data: <intro.json> }
* - prose_chapters → 1 intro segment + 8 chapters = 9 docs
*
* Usage:
* npx tsx scripts/seed-rv.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 RV_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "rv");
/** 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 RvIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type RvIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type RvChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedRv(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(RV_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ RV intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<RvIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "rv" });
await metaCol.insertOne({ book: "rv", data: intro });
console.log(" 📖 RV book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "rv" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(RV_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<RvIntroSegmentJson>(filePath);
docs.push({
book: "rv",
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 08.json)
const chaptersDir = path.join(RV_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<RvChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "rv",
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: "rv" });
console.log(` ✅ RV: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Rāja-vidyā: The King of Knowledge...\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 seedRv(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);
});
+239
View File
@@ -0,0 +1,239 @@
/**
* seed-ssr.ts — Push The Science of Self-Realization JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-hkh.ts) to avoid
* Prisma replica-set write requirements.
*
* SSR is a large prose book with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Sub-chapters are stored with slugs like "1-1", "1-2", "2-1", etc.
* Each sub-chapter JSON has parentChapter and subChapter fields used to
* compute a stable ordering: parentChapter * 100 + subChapter.
*
* Collections written:
* - book_meta → one doc { book: "ssr", data: <intro.json> }
* - prose_chapters → 6 intro segments + ~50 sub-chapters
*
* Usage:
* npx tsx scripts/seed-ssr.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 SSR_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ssr");
/** 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 SsrIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: {
number: number;
title: string;
subChapters: { slug: string; title: string }[];
}[];
};
type SsrIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type SsrChapterJson = {
book: string;
chapter: string; // "1-1", "2-3", etc.
parentChapter: number;
subChapter: number;
title: string;
parentTitle: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedSsr(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(SSR_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ SSR intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<SsrIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "ssr" });
await metaCol.insertOne({ book: "ssr", data: intro });
console.log(" 📖 SSR book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "ssr" });
const docs: Record<string, unknown>[] = [];
// Intro segments (6 total)
const introSegmentFiles = [
"front-pages.json",
"dedication.json",
"foreword.json",
"introduction.json",
"appreciations.json",
"about-the-author.json",
];
const introDir = path.join(SSR_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<SsrIntroSegmentJson>(filePath);
docs.push({
book: "ssr",
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}`);
// Sub-chapters (1-1.json, 1-2.json, 2-1.json, etc.)
const chaptersDir = path.join(SSR_DIR, "chapters");
let chapterCount = 0;
if (fs.existsSync(chaptersDir)) {
const chapterFiles = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort((a, b) => {
// Sort by parentChapter then subChapter numerically
const parseSlug = (name: string) => {
const slug = name.replace(".json", "");
const parts = slug.split("-");
return {
parent: parseInt(parts[0], 10),
sub: parseInt(parts[1], 10),
};
};
const aa = parseSlug(a);
const bb = parseSlug(b);
if (aa.parent !== bb.parent) return aa.parent - bb.parent;
return aa.sub - bb.sub;
});
for (const file of chapterFiles) {
const ch = readJson<SsrChapterJson>(path.join(chaptersDir, file));
const order = ch.parentChapter * 100 + ch.subChapter;
docs.push({
book: "ssr",
volume: NO_VOLUME,
chapter: null, // null since chapter is a string "1-1" not an int
title: ch.title,
type: "chapter",
slug: ch.chapter, // "1-1", "1-2", "2-1", etc.
order,
paragraphs: ch.paragraphs,
});
chapterCount++;
}
console.log(` 📖 Sub-chapters: ${chapterCount}`);
}
// Insert all docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "ssr" });
console.log(` ✅ SSR: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding The Science of Self-Realization...\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 seedSsr(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-te.ts — Push Two Essays JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-ej.ts) to avoid
* Prisma replica-set write requirements.
*
* Two Essays is a collection of 2 standalone essays using the NEW `essays`
* collection (not `prose_chapters`).
*
* Collections written:
* - book_meta → one doc { book: "te", data: <intro.json> }
* - essays → 2 essay docs
*
* Usage:
* npx tsx scripts/seed-te.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 TE_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "te");
// ---------------------------------------------------------------------------
// 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 TeIntroJson = {
group: string;
title: string;
author: string;
year: number;
description: string;
essays: { slug: string; title: string; order: number }[];
};
type TeEssayJson = {
slug: string;
title: string;
author: string;
year: number;
group: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedTe(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(TE_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ TE intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<TeIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "te" });
await metaCol.insertOne({ book: "te", data: intro });
console.log(" 📖 TE book meta inserted");
// ---- 2. Essays collection ----
const essaysCol = db.collection("essays");
await essaysCol.deleteMany({ group: "two-essays" });
const docs: Record<string, unknown>[] = [];
const essaysDir = path.join(TE_DIR, "essays");
if (!fs.existsSync(essaysDir)) {
console.error("❌ TE essays directory not found at", essaysDir);
process.exit(1);
}
const essayFiles = fs
.readdirSync(essaysDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of essayFiles) {
const essay = readJson<TeEssayJson>(path.join(essaysDir, file));
docs.push({
slug: essay.slug,
title: essay.title,
author: essay.author,
year: essay.year,
group: essay.group,
order: essay.order,
paragraphs: essay.paragraphs,
});
}
console.log(` 📝 Essays found: ${docs.length}`);
// Insert all docs
console.log(`\n Inserting ${docs.length} essay documents...`);
await insertBatched(essaysCol, docs);
const totalDocs = await essaysCol.countDocuments({ group: "two-essays" });
console.log(` ✅ TE: ${totalDocs} essays in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Two 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 seedTe(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);
});
+207
View File
@@ -0,0 +1,207 @@
/**
* seed-tlc.ts — Push Teachings of Lord Caitanya JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
* Prisma replica-set write requirements.
*
* TLC is a prose book (like NoD) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "tlc", data: <intro.json> }
* - prose_chapters → 9 intro segments + 32 chapters
*
* Usage:
* npx tsx scripts/seed-tlc.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 TLC_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "tlc");
/** 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 TlcIntroJson = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: { number: number; title: string; chapters: number[] }[];
chapters: { number: number; title: string }[];
};
type TlcIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type TlcChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedTlc(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(TLC_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ TLC intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<TlcIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "tlc" });
await metaCol.insertOne({ book: "tlc", data: intro });
console.log(" 📖 TLC book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "tlc" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introDir = path.join(TLC_DIR, "intro");
if (fs.existsSync(introDir)) {
const introFiles = fs
.readdirSync(introDir)
.filter((f) => f.endsWith(".json"))
.sort();
let fallbackOrder = 0;
for (const file of introFiles) {
const seg = readJson<TlcIntroSegmentJson>(path.join(introDir, file));
fallbackOrder++;
docs.push({
book: "tlc",
volume: NO_VOLUME,
chapter: null,
title: seg.title,
type: seg.type || "intro-segment",
slug: `intro-${seg.slug || file.replace(".json", "")}`,
order: seg.order ?? fallbackOrder,
paragraphs: seg.paragraphs,
});
}
console.log(` 📄 Intro segments: ${introFiles.length}`);
}
// Chapters (01.json – 32.json)
const chaptersDir = path.join(TLC_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<TlcChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "tlc",
volume: NO_VOLUME,
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 docs
console.log(`\n Inserting ${docs.length} documents...`);
await insertBatched(proseCol, docs);
const totalDocs = await proseCol.countDocuments({ book: "tlc" });
console.log(` ✅ TLC: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Teachings of Lord Caitanya...\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 seedTlc(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);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* seed-ttp.ts — Push Transcendental Teachings of Prahlāda Mahārāja JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-poy.ts) to avoid
* Prisma replica-set write requirements.
*
* TTP is a prose book (like POY) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "ttp", data: <intro.json> }
* - prose_chapters → 1 intro segment + 5 chapters = 6 docs
*
* Usage:
* npx tsx scripts/seed-ttp.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 TTP_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ttp");
/** 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 TtpIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type TtpIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type TtpChapterJson = {
book: string;
chapter: number;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedTtp(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(TTP_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ TTP intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<TtpIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "ttp" });
await metaCol.insertOne({ book: "ttp", data: intro });
console.log(" 📖 TTP book meta inserted");
// ---- 2. Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "ttp" });
const docs: Record<string, unknown>[] = [];
// Intro segments
const introSegmentFiles = ["front-pages.json"];
const introDir = path.join(TTP_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<TtpIntroSegmentJson>(filePath);
docs.push({
book: "ttp",
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 05.json)
const chaptersDir = path.join(TTP_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<TtpChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "ttp",
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: "ttp" });
console.log(` ✅ TTP: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Transcendental Teachings of Prahlāda Mahārāja...\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 seedTtp(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);
});
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""Extract Two Essays (1966) from other.html.
The book contains 2 essays:
1. "Krishna The Reservoir Of Pleasure"
2. "Who Is Crazy?"
These are plain English essays from 1966 — NO ScaGoudy encoding.
Output:
lib/jsons/books/te/intro.json — Book metadata
lib/jsons/books/te/essays/krishna-reservoir-of-pleasure.json
lib/jsons/books/te/essays/who-is-crazy.json
Usage:
python3 scripts/te_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)
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 (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)
# ---------------------------------------------------------------------------
# CSS classes to SKIP entirely (ad block, structural headers, etc.)
# ---------------------------------------------------------------------------
SKIP_CLASSES = {"p12", "p121", "p122", "p13", "p123", "p124", "p23", "p18"}
# CSS classes that contain essay body content
BODY_CLASSES = {"p28", "p6", "p22", "p3"}
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_te_boundaries(all_paragraphs: list[Tag]) -> tuple[int, int]:
"""Find the start and end paragraph indices for Two Essays content.
Start: <p class="p12"><b>TWO ESSAYS</b></p>
End: Next <p class="p12"> or <p class="p24"> or <p class="p34"> after our content,
or just past the last essay paragraph.
Returns (start_idx, end_idx) — exclusive end.
"""
te_start = None
te_end = None
for i, p in enumerate(all_paragraphs):
css = get_css_class(p)
text = p.get_text().strip().upper()
# Find the TWO ESSAYS header
if css == "p12" and "TWO ESSAYS" in text:
te_start = i
continue
# Once we've found the start, look for the end
if te_start is not None and te_end is None:
# A new p12 header means we've left Two Essays
if css == "p12" and i > te_start:
te_end = i
break
# Collier end markers
if css in ("p24", "p34"):
te_end = i
break
if te_start is None:
raise ValueError("Could not find TWO ESSAYS header in HTML")
# If we didn't find an explicit end marker, scan for the last body paragraph
if te_end is None:
# Walk backwards from the end to find the last relevant paragraph
for i in range(len(all_paragraphs) - 1, te_start, -1):
css = get_css_class(all_paragraphs[i])
if css in BODY_CLASSES:
te_end = i + 1
break
if te_end is None:
te_end = len(all_paragraphs)
return te_start, te_end
def find_essay_headings(
all_paragraphs: list[Tag], start: int, end: int
) -> list[tuple[int, str]]:
"""Find p27 headings within the Two Essays range.
Returns list of (paragraph_index, title_text).
"""
headings = []
for i in range(start, end):
p = all_paragraphs[i]
css = get_css_class(p)
if css == "p27":
title = p.get_text().strip()
headings.append((i, title))
return headings
# ---------------------------------------------------------------------------
# Paragraph extraction
# ---------------------------------------------------------------------------
def extract_essay_paragraphs(
all_paragraphs: list[Tag], start: int, end: int
) -> list[str]:
"""Extract body paragraphs from the given range.
Includes: p28, p6, p22, p3
Skips: SKIP_CLASSES, p27 (headings), empty paragraphs
"""
paragraphs = []
for i in range(start, end):
p = all_paragraphs[i]
css = get_css_class(p)
# Skip classes we don't want
if css in SKIP_CLASSES:
continue
# Skip headings (already handled)
if css == "p27":
continue
# Only accept body classes
if css not in BODY_CLASSES:
continue
# Skip empty paragraphs
if is_empty(p):
continue
html = clean_html_content(p)
if html and html != "&nbsp;":
paragraphs.append(html)
return paragraphs
# ---------------------------------------------------------------------------
# Main extraction
# ---------------------------------------------------------------------------
def extract_te(soup: BeautifulSoup) -> dict:
"""Extract Two Essays from the parsed HTML.
Returns a dict with:
"essays": list of essay dicts
"essay_listing": list of {slug, title, order} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find Two Essays boundaries
te_start, te_end = find_te_boundaries(all_paragraphs)
print(f" Two Essays content: paragraphs {te_start} to {te_end}")
print(f" Total paragraphs in range: {te_end - te_start}")
# Step 2: Find essay headings (p27)
headings = find_essay_headings(all_paragraphs, te_start, te_end)
print(f" Found {len(headings)} essay headings")
for pos, title in headings:
print(f" paragraph {pos}: {title}")
if len(headings) != 2:
print(f" WARNING: Expected 2 essay headings, found {len(headings)}")
# Step 3: Extract essays
essays = []
essay_configs = [
{
"slug": "krishna-reservoir-of-pleasure",
"title": "Krishna The Reservoir Of Pleasure",
"order": 1,
},
{
"slug": "who-is-crazy",
"title": "Who Is Crazy?",
"order": 2,
},
]
for idx, (heading_pos, heading_title) in enumerate(headings):
config = essay_configs[idx] if idx < len(essay_configs) else None
# Content starts after the heading
content_start = heading_pos + 1
# Content ends at next heading or te_end
if idx + 1 < len(headings):
content_end = headings[idx + 1][0]
else:
content_end = te_end
paragraphs = extract_essay_paragraphs(
all_paragraphs, content_start, content_end
)
if config:
slug = config["slug"]
title = config["title"]
order = config["order"]
else:
# Fallback for unexpected essays
slug = re.sub(r"[^a-z0-9]+", "-", heading_title.lower()).strip("-")
title = heading_title
order = idx + 1
essay = {
"slug": slug,
"title": title,
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1966,
"group": "two-essays",
"order": order,
"paragraphs": paragraphs,
}
essays.append(essay)
# Step 4: Build essay listing for intro.json
essay_listing = [
{"slug": e["slug"], "title": e["title"], "order": e["order"]} for e in essays
]
return {
"essays": essays,
"essay_listing": essay_listing,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "te")
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 Two Essays (1966)...\n")
result = extract_te(soup)
essays = result["essays"]
essay_listing = result["essay_listing"]
# --- Write intro.json ---
intro_meta = {
"group": "two-essays",
"title": "Two Essays",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1966,
"description": "Two foundational essays: 'Krishna The Reservoir Of Pleasure' and 'Who Is Crazy?'",
"essays": essay_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write essay files ---
print(f"\n--- Essays ---")
for essay in essays:
filepath = os.path.join(output_base, "essays", f"{essay['slug']}.json")
write_json(essay, filepath)
print(
f" {essay['order']}. {essay['title']}: "
f"{len(essay['paragraphs'])} paragraphs → {filepath}"
)
# --- Summary ---
total_paras = sum(len(e["paragraphs"]) for e in essays)
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Essays: {len(essays)}")
print(f" Total paragraphs: {total_paras}")
for e in essays:
print(f" {e['order']}. {e['title']}: {len(e['paragraphs'])} paragraphs")
if essays:
print(f" Avg paras/essay: {total_paras / len(essays):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
for essay in essays:
print(f"\n--- Sample: {essay['title']} ---")
for i, p in enumerate(essay["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check both essays present
expected_slugs = {"krishna-reservoir-of-pleasure", "who-is-crazy"}
actual_slugs = {e["slug"] for e in essays}
missing = expected_slugs - actual_slugs
extra = actual_slugs - expected_slugs
if missing:
print(f" ⚠ Missing essays: {missing}")
if extra:
print(f" ⚠ Extra essays: {extra}")
if not missing and not extra:
print(f" ✅ Both essays present")
# Check empty essays
empty_essays = [e["slug"] for e in essays if len(e["paragraphs"]) == 0]
if empty_essays:
print(f" ⚠ Empty essays (0 paragraphs): {empty_essays}")
else:
print(f" ✅ No empty essays")
# Check paragraph counts are reasonable
for e in essays:
count = len(e["paragraphs"])
if count < 5:
print(
f" ⚠ Essay '{e['slug']}' has only {count} paragraphs (suspiciously low)"
)
elif count > 100:
print(f" ⚠ Essay '{e['slug']}' has {count} paragraphs (suspiciously high)")
else:
print(f" ✅ Essay '{e['slug']}' has {count} paragraphs (reasonable)")
# Check for residual span class artifacts
span_count = 0
for e in essays:
for p in e["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check no ScaGoudy encoding present (these are plain English essays)
# But verify key words appear as plain English (not IAST)
all_text = " ".join(" ".join(e["paragraphs"][:10]) for e in essays)
if "Krishna" in all_text:
print(f" ✅ 'Krishna' found (plain English, as expected for 1966)")
else:
print(f" ⚠ 'Krishna' not found in text")
if "Bhagavad Gita" in all_text or "Bhagavad-Gita" in all_text:
print(f" ✅ 'Bhagavad Gita' found (plain English)")
# Check for ad content leaking in
ad_markers = ["ISKCON", "26 2nd Ave", "SRIMAD-BHAGAWATAM", "$3.25", "$16.80"]
leaked_ads = []
for e in essays:
for p in e["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
for marker in ad_markers:
if marker in plain:
leaked_ads.append((e["slug"], marker, plain[:80]))
break
if leaked_ads:
print(f" ⚠ {len(leaked_ads)} paragraphs may contain ad content:")
for slug, marker, preview in leaked_ads[:3]:
print(f" [{slug}] matched '{marker}': {preview}...")
else:
print(f" ✅ No ad content detected in essays")
# Check for SKIP_CLASSES content leaking in
# (p18 paragraphs contain ad text like "BLISS—what was once only a word...")
bliss_check = False
for e in essays:
for p in e["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if plain.startswith("BLISS") or "SOUNDS OF KRISHNA CONSCIOUSNESS" in plain:
bliss_check = True
break
if bliss_check:
print(f" ⚠ Ad text ('BLISS...') leaked into essay paragraphs")
else:
print(f" ✅ Ad text properly excluded")
# Show last paragraph of each essay
print(f"\n--- Last Paragraph of Each Essay ---")
for e in essays:
if e["paragraphs"]:
last = re.sub(r"<[^>]+>", "", e["paragraphs"][-1])[:150]
print(f" {e['title']}:")
print(f" ...{last}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+868
View File
@@ -0,0 +1,868 @@
#!/usr/bin/env python3
"""Extract Teachings of Lord Caitanya (1968 Edition) from other.html.
The book has 9 intro segments, 32 chapters in 2 parts.
IMPORTANT: This is the 1968 edition with plain English transliterations.
NO ScaGoudy font decoding is needed.
Output:
lib/jsons/books/tlc/intro.json — Book metadata
lib/jsons/books/tlc/intro/*.json — Intro segments
lib/jsons/books/tlc/chapters/*.json — Chapters
Usage:
python3 scripts/tlc_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# CSS class categorization for TLC
# ---------------------------------------------------------------------------
# Bookmark class — used for section boundary detection
BOOKMARK_CLASS = "p13"
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p6", "p44", "p18", "p30"])
# Section heading (bold) — "Chapter One", "Preface", etc.
SECTION_HEADING_CLASS = "p31"
# Chapter title (bold) — "Teachings to Rupa Goswami", etc.
CHAPTER_TITLE_CLASS = "p27"
# Part header class — "PART I", "PART II"
PART_HEADER_CLASS = "p34"
# Title page / copyright text classes (in intro area)
TITLE_PAGE_CLASS = "p3"
COPYRIGHT_CLASS = "p32"
# Italic verse quote and attribution
VERSE_QUOTE_CLASS = "p39"
VERSE_ATTRIBUTION_CLASS = "p40"
# Photo/painting credits
CREDITS_CLASS = "p41"
# Attribution text ("The Publishers")
ATTRIBUTION_CLASS = "p42"
# Section title bold text
SECTION_TITLE_CLASS = "p43"
# Numbered items (in Mission section)
NUMBERED_ITEM_CLASS = "p24"
# Italic text (e.g., "Hari! Hari!")
ITALIC_TEXT_CLASS = "p19"
# Classes to skip entirely (empty/decorative)
SKIP_CLASSES = frozenset(["p9", "p15"])
# ---------------------------------------------------------------------------
# Intro segment definitions
# ---------------------------------------------------------------------------
INTRO_SEGMENTS = [
{
"slug": "front-flap",
"title": "Front Flap & Pages",
"order": 1,
"bookmark_text": "TLC-1968 Front flap & pages",
},
{
"slug": "the-publishers",
"title": "The Publishers",
"order": 2,
"bookmark_text": "TLC-1968 The Publishers",
},
{
"slug": "dedication",
"title": "Dedication",
"order": 3,
"bookmark_text": "TLC-1968 DEDICATION",
},
{
"slug": "acknowledgement",
"title": "Acknowledgement",
"order": 4,
"bookmark_text": "TLC-1968 ACKNOWLEDGEMENT",
},
{
"slug": "the-author",
"title": "The Author",
"order": 5,
"bookmark_text": "TLC-1968 The Author",
},
{
"slug": "preface",
"title": "Preface",
"order": 6,
"bookmark_text": "TLC-1968 Preface",
},
{
"slug": "prologue",
"title": "Prologue",
"order": 7,
"bookmark_text": "TLC-1968 Prologue",
},
{
"slug": "mission",
"title": "Lord Chaitanya's Mission",
"order": 8,
"bookmark_text": "TLC-1968 Mission",
},
{
"slug": "introduction",
"title": "Introduction",
"order": 9,
"bookmark_text": "TLC-1968 Intoduction", # Note: typo in original
},
]
# ---------------------------------------------------------------------------
# HTML content helpers
# ---------------------------------------------------------------------------
def clean_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, strip span artifacts, preserve formatting tags.
IMPORTANT: No ScaGoudy decoding — this is the 1968 plain English edition.
"""
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 (but preserve <br> tags)
inner = re.sub(r"\s+", " ", inner).strip()
return inner
def get_plain_text(tag: Tag) -> str:
"""Get plain text from a tag (no decoding needed for 1968 edition)."""
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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_tlc_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the TLC content.
Start: <p class="p12"> containing "Teachings of Lord Caitanya"
End: next <p class="p12"> after start, or a dashed line followed by p3,
or end of file
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = get_plain_text(p)
if "Teachings of Lord Caitanya" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after TLC start = end of TLC
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Teachings of Lord Caitanya' header in other.html!"
)
sys.exit(1)
# Also check for the dashed-line boundary before end_idx
# The last content is followed by a dashed line, then the next book starts
for i in range(start_idx + 1, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
text = get_plain_text(p)
# A dashed line in a p6 paragraph followed by a p3 paragraph = end boundary
if cls == "p6" and is_dash_line(text):
# Check if next non-empty paragraph is p3 (start of next book)
for j in range(i + 1, min(i + 5, end_idx)):
next_cls = get_css_class(all_paragraphs[j])
if next_cls == "p3":
end_idx = i # End before the dashed line
break
if end_idx == i:
break
return start_idx, end_idx
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all p13 bookmark tags in the TLC range.
Returns list of (paragraph_index, bookmark_text).
"""
bookmarks = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == BOOKMARK_CLASS:
text = get_plain_text(p)
if "TLC" in text:
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str) -> tuple:
"""Parse a chapter bookmark like 'TLC 1-1968: Teachings to Rupa Goswami'.
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"TLC\s+(\d+)-1968:\s*(.+)", text.strip())
if m:
return int(m.group(1)), m.group(2).strip()
return None
def parse_intro_bookmark(text: str) -> str:
"""Parse an intro bookmark like 'TLC-1968 Preface'.
Returns the segment name or None if not an intro bookmark.
"""
m = re.match(r"TLC-1968\s+(.+)", text.strip())
if m:
return m.group(1).strip()
return None
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_intro: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the TLC content.
No ScaGoudy decoding is applied.
"""
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 noise classes
if cls in SKIP_CLASSES:
continue
# Skip bookmark tags
if cls == BOOKMARK_CLASS:
continue
# Skip book title header
if cls == BOOK_HEADER_CLASS:
continue
# Check for dash lines — skip them
text = get_plain_text(p)
if is_dash_line(text):
continue
# Part headers (p34) — include as bold paragraph
if cls == PART_HEADER_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Section heading (p31) — include as paragraph
if cls == SECTION_HEADING_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Chapter title (p27) — include as bold paragraph
if cls == CHAPTER_TITLE_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Verse quote (p39) — format as centered italic div
if cls == VERSE_QUOTE_CLASS:
html = clean_html_content(p)
if html:
# Strip inner <i></i> tags before re-wrapping
stripped = re.sub(r"</?i>", "", html)
result.append(f'<div style="text-align:center"><i>{stripped}</i></div>')
continue
# Verse attribution (p40) — format as centered italic div
if cls == VERSE_ATTRIBUTION_CLASS:
html = clean_html_content(p)
if html:
stripped = re.sub(r"</?i>", "", html)
result.append(f'<div style="text-align:center"><i>{stripped}</i></div>')
continue
# Title page / copyright text (in intro area)
if cls == TITLE_PAGE_CLASS:
html = clean_html_content(p)
if html and not is_dash_line(get_plain_text(p)):
# Detect Sanskrit verses disguised as title-page paragraphs
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Copyright text (p32)
if cls == COPYRIGHT_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Photo/painting credits (p41)
if cls == CREDITS_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Attribution text (p42) — "The Publishers"
if cls == ATTRIBUTION_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Section title bold text (p43)
if cls == SECTION_TITLE_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Numbered items (p24) — include as regular paragraphs
if cls == NUMBERED_ITEM_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Italic text like "Hari! Hari!" (p19)
if cls == ITALIC_TEXT_CLASS:
html = clean_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6, p44, p18, p30)
if cls in BODY_CLASSES:
html = clean_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = clean_html_content(p)
if html and not is_dash_line(text):
result.append(html)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_tlc(soup: BeautifulSoup) -> dict:
"""Extract the entire Teachings of Lord Caitanya from the parsed HTML.
Returns a dict with:
"intro_segments": list of intro segment dicts
"chapters": list of chapter dicts
"parts": list of part assignment dicts
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find TLC boundaries
tlc_start, tlc_end = find_tlc_boundaries(all_paragraphs)
print(f" TLC content: paragraphs {tlc_start} to {tlc_end}")
print(f" Total paragraphs in range: {tlc_end - tlc_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, tlc_start, tlc_end)
print(f" Found {len(bookmarks)} TLC bookmarks")
# Step 3: Categorize bookmarks into intro vs chapter
intro_bookmarks = [] # (para_idx, bookmark_text)
chapter_bookmarks = [] # (para_idx, chapter_num, title)
for pos, text in bookmarks:
ch = parse_chapter_bookmark(text)
if ch:
chapter_bookmarks.append((pos, ch[0], ch[1]))
else:
intro_name = parse_intro_bookmark(text)
if intro_name:
intro_bookmarks.append((pos, text))
print(f" Intro bookmarks: {len(intro_bookmarks)}")
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
# Step 4: Track PART assignments by scanning for p34 tags
part_assignments = {} # chapter_num -> part_num
parts_data = []
part_positions = [] # (para_idx, part_title)
for i in range(tlc_start, tlc_end):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == PART_HEADER_CLASS:
text = get_plain_text(p)
if text:
part_positions.append((i, text))
print(f" Part headers found: {len(part_positions)}")
for pos, title in part_positions:
print(f" Paragraph ~{pos}: {title}")
# Map part positions to part numbers
part_number_map = {}
for pos, title in part_positions:
title_upper = title.upper().strip()
if "II" in title_upper and "III" not in title_upper:
part_number_map[pos] = 2
elif "I" in title_upper:
part_number_map[pos] = 1
# Assign parts to chapters based on position
sorted_part_positions = sorted(part_number_map.keys())
for ch_pos, ch_num, ch_title in chapter_bookmarks:
assigned_part = None
for pp in sorted_part_positions:
if pp < ch_pos:
assigned_part = part_number_map[pp]
else:
break
if assigned_part:
part_assignments[ch_num] = assigned_part
# Build parts_data
for part_num in sorted(set(part_assignments.values())):
chs = sorted([ch for ch, pn in part_assignments.items() if pn == part_num])
part_title = f"Part {'I' if part_num == 1 else 'II'}"
parts_data.append(
{
"number": part_num,
"title": part_title,
"chapters": chs,
}
)
# Step 5: Collect all section start positions for boundary detection
all_section_starts = sorted(
[pos for pos, _ in intro_bookmarks] + [pos for pos, _, _ in chapter_bookmarks]
)
# Step 6: Extract intro segments
intro_segments = []
for seg_def in INTRO_SEGMENTS:
# Find matching bookmark
bookmark_pos = None
for pos, btext in intro_bookmarks:
if btext.strip() == seg_def["bookmark_text"]:
bookmark_pos = pos
break
if bookmark_pos is None:
print(f" WARNING: Could not find bookmark for '{seg_def['slug']}'")
continue
# Content starts after the bookmark
content_start = bookmark_pos + 1
# Content ends at the next bookmark or part header or tlc_end
content_end = tlc_end
for s in all_section_starts:
if s > bookmark_pos:
content_end = s
break
# Also check if a PART header comes before the next bookmark
for pp in sorted_part_positions:
if pp > bookmark_pos and pp < content_end:
content_end = pp
break
paragraphs = extract_paragraphs(
all_paragraphs, content_start, content_end, is_intro=True
)
# Use the corrected title (e.g., "Introduction" not "Intoduction")
seg = {
"slug": seg_def["slug"],
"title": seg_def["title"],
"type": "intro-segment",
"book": "tlc",
"order": seg_def["order"],
"paragraphs": paragraphs,
}
intro_segments.append(seg)
# Step 7: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
# Content ends at next chapter bookmark or tlc_end
if idx + 1 < len(chapter_bookmarks):
content_end = chapter_bookmarks[idx + 1][0]
else:
content_end = tlc_end
# For the PART header: if a PART header appears right before this
# chapter's bookmark, include it in this chapter's content range.
actual_start = content_start
for pp in sorted_part_positions:
if pp < ch_pos and pp >= content_start - 5:
# Check that there's no other bookmark between pp and ch_pos
has_other_bookmark = False
for bpos, _ in bookmarks:
if bpos > pp and bpos < ch_pos:
has_other_bookmark = True
break
if not has_other_bookmark:
actual_start = pp
paragraphs = extract_paragraphs(all_paragraphs, actual_start, content_end)
ch = {
"book": "tlc",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 8: 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,
"parts": parts_data,
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "tlc")
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 Teachings of Lord Caitanya (1968 Edition)...\n")
result = extract_tlc(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
parts = result["parts"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "tlc",
"title": "Teachings of Lord Caitanya",
"subtitle": "A Treatise on Factual Spiritual Life",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1968,
"description": "The sublime life and teachings of Lord Caitanya Mahāprabhu, the most munificent incarnation of the Supreme Lord.",
"totalChapters": 32,
"parts": parts,
"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)
part_info = ""
for p in parts:
if ch["chapter"] in p["chapters"]:
part_info = f" [Part {p['number']}]"
break
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}{part_info}"
)
# --- 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)}")
print(f" Chapters: {len(chapters)}")
print(f" Parts: {len(parts)}")
for p in parts:
print(
f" Part {p['number']} ({p['title']}): chapters {p['chapters'][0]}-{p['chapters'][-1]} ({len(p['chapters'])} chapters)"
)
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: Chapter 1 ---")
if chapters:
ch1 = chapters[0]
for i, p in enumerate(ch1["paragraphs"][:4]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(f"\n--- Sample: Chapter {chapters[-1]['chapter']} (last) ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if intro_segments:
print(f"\n--- Sample: First intro segment ({intro_segments[0]['title']}) ---")
first_intro = intro_segments[0]
for i, p in enumerate(first_intro["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 32 chapters present
expected_chapters = set(range(1, 33))
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 32 chapters present")
# Check all 9 intro segments present
expected_intros = {
"front-flap",
"the-publishers",
"dedication",
"acknowledgement",
"the-author",
"preface",
"prologue",
"mission",
"introduction",
}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 9 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check part assignments
all_part_chapters = set()
for p in parts:
all_part_chapters.update(p["chapters"])
unassigned = [
ch["chapter"] for ch in chapters if ch["chapter"] not in all_part_chapters
]
if unassigned:
print(f" ⚠ Chapters not assigned to any part: {unassigned}")
else:
print(f" ✅ All chapters assigned to parts")
# Verify Part I has chapters 1-16 and Part II has chapters 17-32
for p in parts:
if p["number"] == 1:
expected = list(range(1, 17))
if p["chapters"] == expected:
print(f" ✅ Part I correctly has chapters 1-16")
else:
print(f" ⚠ Part I has chapters {p['chapters']}, expected {expected}")
elif p["number"] == 2:
expected = list(range(17, 33))
if p["chapters"] == expected:
print(f" ✅ Part II correctly has chapters 17-32")
else:
print(f" ⚠ Part II has chapters {p['chapters']}, expected {expected}")
# Verify no ScaGoudy artifacts (there shouldn't be any for 1968 edition)
# Look for common ScaGoudy encoded words that would indicate wrongly applied decoding
print(f"\n--- 1968 Edition Check ---")
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"][:10])
if "Krishna" in ch1_text:
print(f" ✅ 'Krishna' found in Chapter 1 (plain English as expected)")
if "Kṛṣṇa" in ch1_text:
print(
f" ℹ 'Kṛṣṇa' (IAST) found in Chapter 1 — unexpected for 1968 edition"
)
if "Goswami" in ch1_text or "Rupa Goswami" in ch1_text:
print(f" ✅ 'Goswami' found (plain English as expected)")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+641
View File
@@ -0,0 +1,641 @@
#!/usr/bin/env python3
"""Extract Transcendental Teachings of Prahlāda Mahārāja (TTP) from other.html.
The book has 1 intro segment (front-pages) and 5 chapters.
There are NO p13 bookmarks — chapters are detected via p27 headings.
Output:
lib/jsons/books/ttp/intro.json — Book metadata
lib/jsons/books/ttp/intro/*.json — Intro segments
lib/jsons/books/ttp/chapters/*.json — Chapters
Usage:
python3 scripts/ttp_extract.py
"""
import json
import os
import re
import sys
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def decode_html_content(tag: Tag) -> str:
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
inner = "".join(str(child) for child in tag.children)
inner = decode_scagoudy(inner)
# Remove span tags with CSS classes (RTF conversion artifacts)
inner = re.sub(
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
)
# 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 decode_text(tag: Tag) -> str:
"""Get plain text from a tag with ScaGoudy decoding."""
return decode_scagoudy(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"
def is_dash_line(text: str) -> bool:
"""Return True if text is only dashes, spaces, and/or whitespace."""
cleaned = text.strip()
if not cleaned:
return False
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
# ---------------------------------------------------------------------------
# CSS class categorization for TTP
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Chapter heading class (used as chapter boundary since no p13 bookmarks)
CHAPTER_HEADING_CLASS = "p27"
# Body paragraph classes (all treated as paragraphs)
BODY_CLASSES = frozenset(["p48", "p6", "p99", "p98", "p71"])
# Front-page specific classes to include
FRONT_PAGE_CLASSES = frozenset(["p69", "p76", "p18"])
# Classes to skip entirely
SKIP_CLASSES = frozenset(["p9", "p15", "p70"])
# Chapter titles mapping (proper IAST decoded)
CHAPTER_TITLES = {
1: "The Dearmost Person",
2: "Move on to the Perfect Goal",
3: "The Highest Welfare Activity",
4: "Cutting Off Ties of Affection",
5: "Prayers by Prahlāda Mahārāja",
}
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_ttp_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the TTP content.
Start: <p class="p12"> containing "Transcendental Teachings of Prahläda"
End: next <p class="p12"> after start
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
if cls != BOOK_HEADER_CLASS:
continue
text = p.get_text().strip()
if "Transcendental Teachings of Prahl" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after TTP start = end of TTP
end_idx = i
break
if start_idx is None:
print(
"ERROR: Could not find 'Transcendental Teachings of Prahlāda' header in other.html!"
)
sys.exit(1)
return start_idx, end_idx
def find_chapter_headings(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
"""Find all chapter headings (p27) in the TTP range.
Parses heading text with pattern: "N.Title" or "N. Title"
Returns list of (paragraph_index, chapter_number, chapter_title).
"""
headings = []
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls != CHAPTER_HEADING_CLASS:
continue
text = decode_text(p)
# Remove bold tags from text content
text = re.sub(r"</?b>", "", text).strip()
# Try to parse chapter number and title
m = re.match(r"(\d+)\s*[./]\s*(.+)", text)
if m:
ch_num = int(m.group(1))
ch_title_raw = m.group(2).strip()
# Strip surrounding quotes if present
ch_title_raw = ch_title_raw.strip('"').strip('"').strip('"')
# Use pre-defined proper IAST titles if available
ch_title = CHAPTER_TITLES.get(ch_num, ch_title_raw)
headings.append((i, ch_num, ch_title))
return headings
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
) -> list:
"""Extract paragraphs for a section (chapter or intro segment).
Handles all CSS classes within the TTP content.
Detects italic+br verses in any body class → centered divs.
"""
result = []
verse_accumulator = []
def flush_verse():
if verse_accumulator:
joined = "<br>".join(verse_accumulator)
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
verse_accumulator.clear()
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
# Skip empty paragraphs
if is_empty(p):
continue
# Skip noise classes
if cls in SKIP_CLASSES:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip chapter headings (p27) — they are boundaries, not content
if cls == CHAPTER_HEADING_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Non-verse paragraph — flush any accumulated verse first
flush_verse()
# Front-page specific classes
if is_front_pages and cls in FRONT_PAGE_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p48, p6, p99, p98, p71)
if cls in BODY_CLASSES:
html = decode_html_content(p)
if html:
# Detect Sanskrit verses disguised as body paragraphs:
# they start with <i> and contain <br> (multi-line italic = verse)
stripped_html = html.strip()
if stripped_html.startswith("<i>") and "<br" in stripped_html:
# This is a verse — wrap in centered italic div
inner = re.sub(r"</?i>", "", stripped_html)
result.append(
f'<div style="text-align:center"><i>{inner}</i></div>'
)
else:
result.append(html)
continue
# Front-page classes even outside is_front_pages context
if cls in FRONT_PAGE_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class — try to extract content (be permissive)
html = decode_html_content(p)
if html and not is_dash_line(text):
result.append(html)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_ttp(soup: BeautifulSoup) -> dict:
"""Extract the entire TTP book 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 TTP boundaries
ttp_start, ttp_end = find_ttp_boundaries(all_paragraphs)
print(f" TTP content: paragraphs {ttp_start} to {ttp_end}")
print(f" Total paragraphs in range: {ttp_end - ttp_start}")
# Step 2: Find all chapter headings (p27)
chapter_headings = find_chapter_headings(all_paragraphs, ttp_start, ttp_end)
print(f" Found {len(chapter_headings)} chapter headings")
for pos, ch_num, ch_title in chapter_headings:
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
# Step 3: Extract front pages
# From book start (ttp_start) to first chapter heading
intro_segments = []
if chapter_headings:
front_pages_end = chapter_headings[0][0]
else:
front_pages_end = ttp_end
front_pages_paragraphs = extract_paragraphs(
all_paragraphs, ttp_start, front_pages_end, is_front_pages=True
)
intro_segments.append(
{
"slug": "front-pages",
"title": "Front Pages",
"type": "intro-segment",
"book": "ttp",
"order": 1,
"paragraphs": front_pages_paragraphs,
}
)
# Step 4: Extract chapters
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_headings):
# Content starts after the heading
content_start = ch_pos + 1
# Content ends at next chapter heading or ttp_end
if idx + 1 < len(chapter_headings):
content_end = chapter_headings[idx + 1][0]
else:
content_end = ttp_end
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "ttp",
"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 {
"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", "other.html")
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ttp")
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 Transcendental Teachings of Prahlāda Mahārāja...\n")
result = extract_ttp(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "ttp",
"title": "Transcendental Teachings of Prahlāda Mahārāja",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1972,
"description": "The profound spiritual teachings of the great devotee Prahlāda Mahārāja from the Śrīmad-Bhāgavatam.",
"totalChapters": len(chapters),
"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)}")
print(f" Chapters: {len(chapters)}")
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)[:100]
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
if chapters and len(chapters) > 1:
print(
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
)
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:100]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 5 chapters present
expected_chapters = set(range(1, 6))
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 5 chapters present")
# Check all 1 intro segment present
expected_intros = {"front-pages"}
actual_intros = {s["slug"] for s in intro_segments}
missing_intros = expected_intros - actual_intros
extra_intros = actual_intros - expected_intros
if missing_intros:
print(f" ⚠ Missing intro segments: {missing_intros}")
if extra_intros:
print(f" ⚠ Extra intro segments: {extra_intros}")
if not missing_intros and not extra_intros:
print(f" ✅ All 1 intro segments 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 empty intro segments
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
if empty_intros:
print(f" ⚠ Empty intro segments: {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding
print(f"\n--- ScaGoudy Decoding Check ---")
all_text = ""
for ch in chapters:
all_text += " ".join(ch["paragraphs"][:20])
for seg in intro_segments:
all_text += " ".join(seg["paragraphs"][:10])
if "Kṛṣṇa" in all_text:
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
if "Prahlāda" in all_text or "Prahlād" in all_text:
print(f" ✅ 'Prahlāda' found (ScaGoudy properly decoded)")
else:
print(f" ⚠ 'Prahlāda' not found — ScaGoudy decoding may have failed")
if "Prabhupāda" in all_text:
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
# Check for un-decoded ScaGoudy remnants
scagoudy_remnants = 0
for ch in chapters:
for p in ch["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if "Kåñëa" in p or "Prabhupäda" in p:
scagoudy_remnants += 1
if scagoudy_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
# Check for centered verse blocks
verse_block_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
if verse_block_count > 0:
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
else:
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
# Check for residual span tags
span_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
if 'class="' in p and "<span" in p:
span_count += 1
if span_count == 0:
print(f" ✅ No residual span class artifacts found")
else:
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
# Check for dash-line paragraphs that shouldn't be there
dash_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
for seg in intro_segments:
for p in seg["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if is_dash_line(plain):
dash_count += 1
if dash_count == 0:
print(f" ✅ No dashed-line paragraphs in output")
else:
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
# Show a verse block sample
print(f"\n--- Sample: Verse Block ---")
found_verse = False
for ch in chapters:
for p in ch["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
found_verse = True
break
if found_verse:
break
if not found_verse:
for seg in intro_segments:
for p in seg["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" {preview}...")
found_verse = True
break
if found_verse:
break
if not found_verse:
print(f" (no verse blocks found)")
# Chapter title decoding check
print(f"\n--- Chapter Title Decoding Check ---")
for ch in chapters:
title = ch["title"]
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
else:
print(f" ✅ Ch {ch['chapter']}: {title}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()