feat: sva extracted and seeded

This commit is contained in:
gosangam
2026-04-30 19:45:17 +05:30
parent 1f2ac2198a
commit ae6a752787
47 changed files with 17163 additions and 205 deletions
+348 -205
View File
@@ -2,9 +2,9 @@
## 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.
A Next.js app that converts legacy RTF files containing Vedic scriptures and teachings by His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda 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.
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. The extraction pipeline decodes these into proper Unicode, generates Devanagari from IAST, and stores everything in MongoDB for serving via a Next.js frontend.
---
@@ -12,8 +12,9 @@ The original RTF files use **custom font encodings** (Indevr, ScaGoudy, Inbenr/I
- **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
- **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer
- **next-themes** for dark/light/system mode
- **vaul** for mobile drawer navigation
- **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
@@ -26,86 +27,130 @@ The original RTF files use **custom font encodings** (Indevr, ScaGoudy, Inbenr/I
```
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
│ ├── page.tsx → Landing page with Logo, links to Books/Essays/Glossary/RTFs
│ ├── layout.tsx → ThemeProvider + TooltipProvider + PasswordGate wrapper
│ ├── globals.css → Vintage color palette + reading settings CSS
│ ├── books/
│ │ ├── page.tsx → Books index (BG, SB, CC, SB1962, KB)
│ │ ├── chapter-viewer.tsx → Shared client component for verse-based books
│ │ ├── page.tsx → Books index (searchable grid, diacritic-aware search)
│ │ ├── books-grid.tsx → Client component for searchable book grid
│ │ ├── chapter-viewer.tsx → Shared client component for verse-based books (BG/SB/CC/SB1962)
│ │ ├── 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
│ │ ├── bg/ → Bhagavad-gītā (18 chapters, 653 verses)
│ │ ├── sb/ → Śrīmad-Bhāgavatam (10 cantos, 8258 verses)
│ │ ├── cc/ → Caitanya-caritāmṛta (3 līlās, 11348 verses)
│ │ ├── sb1962/ → SB First Canto 1962 Edition (19 chapters, 762 verses)
│ │ ├── kb/ → Kṛṣṇa Book (89 chapters, 2 volumes)
│ │ ├── nod/ → The Nectar of Devotion (51 chapters, 4 parts)
│ │ ├── tlc/ → Teachings of Lord Caitanya (32 chapters, 2 parts)
│ │ ├── iso/ → Śrī Īśopaniṣad (18 mantras + invocation)
│ │ ├── noi/ → The Nectar of Instruction (11 texts)
│ │ ├── ej/ → Easy Journey to Other Planets (2 chapters)
│ │ ├── ek/ → Elevation to Kṛṣṇa Consciousness (6 chapters)
│ │ ├── mg/ → Kṛṣṇa Consciousness: The Matchless Gift (7 chapters)
│ │ ├── bbd/ → Beyond Birth and Death (5 chapters)
│ │ ├── owk/ → On the Way to Kṛṣṇa (5 chapters)
│ │ ├── rv/ → Rāja-vidyā: The King of Knowledge (8 chapters)
│ │ ├── poy/ → The Perfection of Yoga (11 chapters)
│ │ ├── pqpa/ → Perfect Questions Perfect Answers (9 chapters)
│ │ ├── ttp/ → Transcendental Teachings of Prahlāda Mahārāja (5 chapters)
│ │ ├── ochkm/ → On Chanting the Hare Kṛṣṇa Mantra (4 chapters)
│ │ ├── hkh/ → The Kṛṣṇa Consciousness Handbook (19 chapters)
│ │ ├── ssr/ → The Science of Self-Realization (8 chapters, 50 sub-chapters)
│ │ └── ds/ → Dialectic Spiritualism (13 chapters, 36 sub-chapters)
│ ├── essays/
│ │ ├── page.tsx → Essays listing page
│ │ └── [slug]/page.tsx → Individual essay reader
│ ├── glossary/
│ │ ├── page.tsx → Server component wrapper
│ │ └── glossary-client.tsx → Searchable client component
│ │ ├── page.tsx → Server component wrapper
│ │ └── glossary-client.tsx → Searchable client component (2749 entries)
│ ├── rtfs/
│ │ ├── page.tsx → RTF file listing (gated by ALLOW_RTF env)
│ │ └── [slug]/page.tsx → RTF iframe viewer (gated by ALLOW_RTF env)
│ │ ├── page.tsx → RTF file listing (gated by ALLOW_RTF env)
│ │ └── [slug]/page.tsx → RTF iframe viewer
│ └── api/
│ ├── books/
│ │ ├── route.ts → GET all book metas
│ │ ├── route.ts → GET all book metas
│ │ └── [book]/
│ │ ├── route.ts → GET single book meta
│ │ ├── route.ts → GET single book meta
│ │ └── verses/route.ts → GET verses (?chapter=&verse=&division=&indexOnly=&page=&limit=)
│ └── glossary/route.ts → GET all glossary entries
│ ├── glossary/route.ts → GET all glossary entries
│ └── verify-access/route.ts → GET (check if password enabled) + POST (validate key)
├── 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
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
│ ├── page-header.tsx → Shared header with ReadingSettings
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility settings panel
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
│ ├── logo.tsx → Theme-aware logo component (light/dark variants)
│ ├── password-gate.tsx → Client-side password protection (runtime check)
│ └── theme-provider.tsx → next-themes wrapper
├── 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
│ ├── db.ts → Prisma client singleton
│ ├── api.ts → Data access layer (getAllBooks, getBookMeta, getChapterVerses, etc.)
│ ├── utils.ts → cn() helper
│ ├── nod.ts → NoD data access (Prisma)
│ ├── tlc.ts → TLC data access (Prisma)
│ ├── iso.ts → Īśopaniṣad data access (Prisma)
│ ├── noi.ts → NoI data access (Prisma)
│ ├── ej.ts → Easy Journey data access (Prisma)
│ ├── ek.ts → Elevation to KC data access (Prisma)
│ ├── mg.ts → Matchless Gift data access (Prisma)
│ ├── bbd.ts → Beyond Birth and Death data access (Prisma)
│ ├── owk.ts → On the Way to Kṛṣṇa data access (Prisma)
│ ├── rv.ts → Rāja-vidyā data access (Prisma)
│ ├── poy.ts → Perfection of Yoga data access (Prisma)
│ ├── pqpa.ts → Perfect Questions data access (Prisma)
│ ├── ttp.ts → Prahlāda Mahārāja data access (Prisma)
│ ├── ochkm.ts → On Chanting data access (Prisma)
│ ├── hkh.ts → KC Handbook data access (Prisma)
│ ├── ssr.ts → Science of Self-Realization data access (Prisma, hierarchical)
│ ├── ds.ts → Dialectic Spiritualism data access (Prisma, hierarchical)
│ ├── te.ts → Two Essays data access (Prisma, uses essays collection)
│ └── jsons/books/ → Extracted JSON data (all books)
│ ├── bg/, sb/, cc/, sb-1962/, kb/ → Original verse/prose books
│ ├── nod/, tlc/, iso/, noi/ → Extracted from other.html
│ ├── ej/, ek/, mg/, bbd/, owk/ → Small prose books
│ ├── rv/, poy/, pqpa/, ttp/, ochkm/ → More prose books
│ ├── hkh/, ssr/, ds/ → Large structured books
│ ├── te/ → Two Essays
│ └── glossary/index.json → 2,749 glossary entries
├── prisma/
│ └── schema.prisma → Verse, BookMeta, ProseChapter, GlossaryEntry models
│ └── schema.prisma → Verse, BookMeta, ProseChapter, Essay, 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)
│ ├── 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
│ ├── nod_extract.py → NoD extraction from other.html
│ ├── tlc_extract.py → TLC extraction from other.html
│ ├── iso_extract.py → Īśopaniṣad extraction from other.html
│ ├── noi_extract.py → NoI extraction from other.html
│ ├── ej_extract.py → Easy Journey extraction
│ ├── ek_extract.py → Elevation to KC extraction
│ ├── mg_extract.py → Matchless Gift extraction
│ ├── bbd_extract.py → Beyond Birth and Death extraction
│ ├── owk_extract.py → On the Way to Kṛṣṇa extraction
│ ├── rv_extract.py → Rāja-vidyā extraction
│ ├── poy_extract.py → Perfection of Yoga extraction
│ ├── pqpa_extract.py → Perfect Questions extraction
│ ├── ttp_extract.py → Prahlāda Mahārāja extraction
│ ├── ochkm_extract.py → On Chanting extraction
│ ├── hkh_extract.py → KC Handbook extraction
│ ├── ssr_extract.py → Science of Self-Realization extraction (hierarchical sub-chapters)
│ ├── ds_extract.py → Dialectic Spiritualism extraction (hierarchical sub-chapters)
│ ├── te_extract.py → Two Essays extraction
│ ├── glossary_extract.py → Glossary extraction
│ ├── seed-db.ts → Main MongoDB seeder (BG, SB, CC, SB1962, KB, glossary)
│ ├── seed-nod.ts … seed-ds.ts → Individual book seeders
│ ├── seed-te.ts → Two Essays seeder (uses essays collection)
│ ├── kb_extract_*.py → KB extraction scripts (intro, vol1, vol2)
│ ├── inject-fonts.py → Inject @font-face rules into converted HTML
│ └── fix-bengali-encoding.py → Fix Bengali font encoding in cc.html
└── 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.)
├── rtfs/ → Original RTF files (~430MB, 17 files)
├── html/ → HTML converted from RTF (textutil + @font-face injection)
├── fonts/ → Custom TTF fonts (Balaram, ScaGoudy, Indevr, Inbenr, RM Devanagari, etc.)
├── logo_dark.png → Logo for light theme (with "says.org")
├── logo_light.png → Logo for dark theme (subtle script)
└── robots.txt → Disallow all crawlers
```
---
@@ -120,8 +165,8 @@ type Synonym {
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
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
chapter Int
verse Int
verseEnd Int?
@@ -143,7 +188,7 @@ model Verse {
model BookMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique // "bg" | "sb" | "cc" | "sb1962" | "kb"
book String @unique
data Json
@@map("book_meta")
@@ -151,11 +196,11 @@ model BookMeta {
model ProseChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "kb"
volume Int
book String
volume Int // 0 = no volumes (sentinel for non-volume books)
chapter Int? // null for intro segments
title String
type String // "chapter" | "intro-segment"
type String // "chapter" | "intro-segment" | "conclusion"
slug String
order Int
paragraphs String[]
@@ -167,6 +212,20 @@ model ProseChapter {
@@map("prose_chapters")
}
model Essay {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
title String
author String
year Int?
group String? // e.g., "two-essays"
order Int
paragraphs String[]
@@index([group])
@@map("essays")
}
model GlossaryEntry {
id String @id @default(auto()) @map("_id") @db.ObjectId
term String
@@ -182,20 +241,56 @@ model GlossaryEntry {
## 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 | — |
### Verse-based Books (stored in `verses` collection)
| Book | Code | Verses | Chapters | Division |
|------|------|--------|----------|----------|
| Bhagavad-gītā As It Is | `bg` | 653 | 18 | — |
| Śrīmad-Bhāgavatam | `sb` | 8,258 | 213 | 10 cantos |
| Śrī Caitanya-caritāmṛta | `cc` | 11,348 | 62 | 3 līlās |
| SB First Canto (1962 Edition) | `sb1962` | 762 | 19 | — |
| Śrī Īśopaniṣad | `iso` | 19 | — | Invocation + 18 mantras |
| The Nectar of Instruction | `noi` | 11 | — | 11 texts |
### Prose Books (stored in `prose_chapters` collection)
| Book | Code | Chapters | Source |
|------|------|----------|--------|
| Kṛṣṇa Book | `kb` | 89 (2 volumes) | other.html |
| The Nectar of Devotion | `nod` | 51 (4 parts) | other.html |
| Teachings of Lord Caitanya | `tlc` | 32 (2 parts) | other.html (1968 ed., plain English) |
| Easy Journey to Other Planets | `ej` | 2 | other.html |
| Elevation to Kṛṣṇa Consciousness | `ek` | 6 | other.html |
| Kṛṣṇa Consciousness: The Matchless Gift | `mg` | 7 | other.html |
| Beyond Birth and Death | `bbd` | 5 | other.html |
| On the Way to Kṛṣṇa | `owk` | 5 | other.html |
| Rāja-vidyā: The King of Knowledge | `rv` | 8 | other.html |
| The Perfection of Yoga | `poy` | 11 | other.html |
| Perfect Questions Perfect Answers | `pqpa` | 9 | other.html |
| Transcendental Teachings of Prahlāda Mahārāja | `ttp` | 5 | other.html |
| On Chanting the Hare Kṛṣṇa Mantra | `ochkm` | 4 | other.html |
| The Kṛṣṇa Consciousness Handbook | `hkh` | 19 | other.html |
| The Science of Self-Realization | `ssr` | 50 sub-chapters (8 parents) | other.html |
| Dialectic Spiritualism | `ds` | 36 sub-chapters (13 parents) | other.html |
### Essays (stored in `essays` collection)
| Essay | Slug | Group |
|-------|------|-------|
| Krishna The Reservoir Of Pleasure | `krishna-reservoir-of-pleasure` | `two-essays` |
| Who Is Crazy? | `who-is-crazy` | `two-essays` |
### Other
| Content | Collection |
|---------|-----------|
| Glossary (2,749 entries) | `glossary` |
---
## Font Encoding System
### ScaGoudy → IAST mapping (used in BG, SB, CC, glossary)
### ScaGoudy → IAST mapping (used in BG, SB, CC, ISO, NOI, NoD, and most 1969+ books)
```
ä→ā Ä→Ā å→ṛ Å→Ṛ ñ→ṣ Ñ→Ṣ ö→ṭ Ö→Ṭ ë→ṇ Ë→Ṇ ò→ḍ Ò→Ḍ
@@ -204,81 +299,137 @@ model GlossaryEntry {
### 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.
Custom-encoded fonts where ASCII codepoints render as Bengali script. Fixed by `fix-bengali-encoding.py`.
### RM Devanagari font (used in ISO, NOI)
Custom-encoded Devanagari font (p51 CSS class). The text appears as garbled ASCII without the font. **Skipped during extraction** — Unicode Devanagari is generated from IAST transliteration instead.
### 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)
- `join_virama_words()` — joins virāma + consonant AND virāma + vowel
- `normalize_devanagari_anusvara()` — ङ् → ं before velars, ञ् → ं before palatals
---
### Books without ScaGoudy encoding
## 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
- **TLC (1968 Edition)** — plain English transliterations ("Krishna" not "Kṛṣṇa")
- **Two Essays (1966)** — plain English
- All other books from other.html use ScaGoudy encoding
---
## KB (Kṛṣṇa Book) — Prose Book
## Book Types and URL Patterns
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
### Verse-based books (use ChapterViewer client component)
| Route Pattern | Books |
|---------------|-------|
| `/books/bg/[chapter]/[verse]` | BG |
| `/books/sb/[canto]/[chapter]/[verse]` | SB |
| `/books/cc/[lila]/[chapter]/[verse]` | CC |
| `/books/sb1962/[chapter]/[verse]` | SB1962 |
| `/books/iso/[mantra]` | Īśopaniṣad (verse + intro segments) |
| `/books/noi/[text]` | Nectar of Instruction (verse + intro segments) |
### Prose books (use per-book data layers + reading pages)
| Route Pattern | Books |
|---------------|-------|
| `/books/kb/[volume]/[slug]` | Kṛṣṇa Book |
| `/books/{code}/[chapter]` | NoD, TLC, EJ, EK, MG, BBD, OWK, RV, POY, PQPA, TTP, OCHKM, HKH |
### Hierarchical prose books (sub-chapters as separate pages)
| Route Pattern | Books |
|---------------|-------|
| `/books/ssr/[chapter]` | Science of Self-Realization (slugs: "1-1", "1-2", "intro-foreword", etc.) |
| `/books/ds/[chapter]` | Dialectic Spiritualism (slugs: "1-1" = Socrates, "1-2" = Plato, etc.) |
### Essays
| Route | Content |
|-------|---------|
| `/essays` | All essays listing |
| `/essays/[slug]` | Individual essay reader |
### Other routes
| Route | Content |
|-------|---------|
| `/` | Landing page |
| `/books` | Searchable book grid |
| `/glossary` | Searchable glossary (2,749 entries) |
| `/rtfs` | RTF viewer (gated by ALLOW_RTF=true) |
| `/api/verify-access` | Password protection endpoint |
---
## Data Access Layer (`lib/api.ts`)
## Volume Convention
```typescript
// Book meta
getAllBooks(): Promise<BookMeta[]>
getBookMeta(book: string): Promise<BookMeta | null>
Books without volumes use `volume: 0` as a sentinel value in the `prose_chapters` collection. This satisfies the Prisma schema's `@@unique([book, volume, slug])` constraint without requiring schema changes.
// 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.
- KB uses `volume: 1` and `volume: 2`
- All other prose books use `volume: 0`
---
## Known Issues
## Hierarchical Books (SSR, DS)
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.
SSR and DS have parent chapters with sub-chapters stored as individual `prose_chapters` docs:
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).
- Sub-chapter slugs: `"1-1"`, `"1-2"`, `"2-1"`, etc.
- Parent chapter info stored in `intro.json` metadata:
```json
{
"chapters": [
{
"number": 1,
"title": "Learning the Science of the Soul",
"subChapters": [
{ "slug": "1-1", "title": "Seeing the Aim of Human Life" },
{ "slug": "1-2", "title": "Your Original Consciousness Is Kṛṣṇa Consciousness" }
]
}
]
}
```
- Sidebar UI shows parent headings (non-clickable) with sub-chapters indented below
- Prev/next navigation skips parent headings
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.
## UI Features
5. **`scripts/` excluded from tsconfig**: Added to `"exclude"` array to prevent TypeScript build errors from seed scripts.
### Reading Settings (accessibility)
- **Theme**: Light / Dark / System toggle
- **Serif Font**: Toggle to switch reading content to Georgia/serif
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
- **Justify Text**: Toggle for justified text alignment on reading content
- Settings persist in `localStorage` under `spsays_reading_prefs`
- Applied via CSS classes on `<html>` element
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.
### Mobile Navigation
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button
- Only visible below `lg` breakpoint
- Shows the full sidebar navigation grouped by section
- Implemented in all 24+ reader pages via shared `MobileNav` component
### Search
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
- Glossary page: full-text search across terms and definitions
- Uses Unicode NFD normalization + manual IAST character stripping
### Color Palette
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary
### Password Protection
- Client-side gate component (`PasswordGate`) wrapping the entire app
- Runtime check via `GET /api/verify-access` (checks if `ACCESS_KEY` env var is set)
- Validation via `POST /api/verify-access` (compares submitted key)
- Stores valid key in `sessionStorage`
- Bypassed when `ACCESS_KEY` is not set
---
@@ -286,7 +437,8 @@ The data access layer includes `normalizeProse()` which handles mixed storage (s
```
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
ALLOW_RTF=true # optional — enables RTF viewer routes
ALLOW_RTF=true # optional — enables RTF viewer routes
ACCESS_KEY="secret" # optional — enables password protection (quote if contains #)
```
---
@@ -295,67 +447,35 @@ ALLOW_RTF=true # optional — enables RTF viewer routes
```bash
# Development
npm run dev # Start dev server
npm run dev
# 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
python3 scripts/bg_extract.py # Extract BG
python3 scripts/sb_extract.py # Extract SB
python3 scripts/cc_extract.py # Extract CC
python3 scripts/nod_extract.py # Extract NoD from other.html
python3 scripts/iso_extract.py # Extract Īśopaniṣad from other.html
python3 scripts/ssr_extract.py # Extract SSR from other.html
python3 scripts/ds_extract.py # Extract DS from other.html
# ... (similar for all other books)
# 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)
# Database seeding
npx tsx scripts/seed-db.ts # Seed BG, SB, CC, SB1962, KB, glossary
npx tsx scripts/seed-nod.ts # Seed NoD
npx tsx scripts/seed-iso.ts # Seed Īśopaniṣad
npx tsx scripts/seed-te.ts # Seed Two Essays
# ... (similar for all other books)
# Build
npx next build # Production build
npx next 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)
## Verse JSON Schema (BG/SB/CC/SB1962/ISO/NOI)
```json
{
@@ -373,39 +493,46 @@ npx next build # Production build
{ "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..."]
"purport": ["paragraph 1...", "paragraph 2..."]
}
```
## Prose Chapter JSON Schema (KB)
## Prose Chapter JSON Schema
```json
{
"book": "kb",
"volume": 1,
"book": "ek",
"chapter": 1,
"title": "Advent of Lord Kṛṣṇa",
"title": "Choosing Human and Animal Lives",
"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..."
]
"paragraphs": ["<b>heading</b>", "body text...", "<div style=\"text-align:center\"><i>verse</i></div>"]
}
```
## Intro Segment JSON Schema (KB)
## Intro Segment JSON Schema
```json
{
"slug": "words-from-apple",
"title": "Words from Apple — by George Harrison",
"slug": "preface",
"title": "Preface",
"type": "intro-segment",
"book": "kb",
"volume": 1,
"order": 3,
"paragraphs": ["<b>Words From Apple</b>", "Everybody is looking for KṚṢṆA.", "..."]
"book": "ek",
"order": 2,
"paragraphs": ["paragraph 1...", "paragraph 2..."]
}
```
## Essay JSON Schema
```json
{
"slug": "krishna-reservoir-of-pleasure",
"title": "Krishna The Reservoir Of Pleasure",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1966,
"group": "two-essays",
"order": 1,
"paragraphs": ["paragraph 1...", "paragraph 2..."]
}
```
@@ -414,7 +541,7 @@ npx next build # Production build
```json
{
"title": "Glossary of Vedic Terms",
"description": "A comprehensive glossary of Sanskrit and Vedic terms...",
"description": "A comprehensive glossary...",
"totalEntries": 2749,
"letters": ["A", "B", "C", ...],
"entries": [
@@ -426,3 +553,19 @@ npx next build # Production build
]
}
```
---
## Known Issues
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time.
2. **SB verse numbering**: 5 duplicate verse reference tags in the HTML. Fixed by using the TEXT N header as authoritative.
3. **RTF routes gated**: `/rtfs` only accessible when `ALLOW_RTF=true`.
4. **`scripts/` excluded from tsconfig**: Prevents TypeScript build errors from seed scripts.
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
+186
View File
@@ -0,0 +1,186 @@
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
type BsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("bs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("bs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `bs-${chapter}-${verse}`,
book: "bs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const bsMeta = await getMetaWithFallback();
if (!bsMeta) notFound();
const chapterInfo = bsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bs"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/bs/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export default async function BsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction, intro-foreword)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(BS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("bs", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/bs/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/bs/${chapter}/${verseIndex[0].verse}`);
}
+176
View File
@@ -0,0 +1,176 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
type BsMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type BsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
async function getMetaWithFallback(): Promise<BsMeta | null> {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): BsIntroSegment[] {
const introDir = path.join(BS_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: BsIntroSegment[] = [];
const files = ["introduction.json", "foreword.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as BsIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function BsPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
const foreword = introSegments.find((s) => s.slug === "foreword");
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>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{(introduction || foreword) && (
<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">
{introduction && (
<li>
<Link
href="/books/bs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
)}
{foreword && (
<li>
<Link
href="/books/bs/intro-foreword"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{foreword.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{foreword.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 py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+343
View File
@@ -0,0 +1,343 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getGgContent,
getGgSidebarItems,
type GgChapter,
type GgSidebarItem,
} from "@/lib/gg";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function GgChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getGgContent(slug);
if (!content) notFound();
const sidebarItems = await getGgSidebarItems();
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 GgChapter).chapter === "number" &&
(content as GgChapter).chapter !== null;
const chapterNum = isChapter ? (content as GgChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/gg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Gītār Gān
</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 */}
<GgSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<GgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<GgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function GgSidebar({
items,
currentSlug,
}: {
items: GgSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: GgSidebarItem[] = [];
const chapterItems: GgSidebarItem[] = [];
const backMatterItems: GgSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.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>
</>
)}
{/* Back Matter */}
{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: GgSidebarItem;
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/gg/${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 GgNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: GgSidebarItem | null;
next: GgSidebarItem | null;
sidebarItems: GgSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: GgSidebarItem[] = [];
const chapterItems: GgSidebarItem[] = [];
const backMatterItems: GgSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Gītār Gān"
/>
{prev ? (
<Link
href={`/books/gg/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/gg/${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>
);
}
+136
View File
@@ -0,0 +1,136 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getGgMeta, getGgIntroSegments } from "@/lib/gg";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function GgPage() {
const meta = await getGgMeta();
if (!meta) notFound();
const introSegments = await getGgIntroSegments();
// 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/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/gg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{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/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+341
View File
@@ -0,0 +1,341 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getLclContent,
getLclSidebarItems,
type LclChapter,
type LclSidebarItem,
} from "@/lib/lcl";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function LclChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getLclContent(slug);
if (!content) notFound();
const sidebarItems = await getLclSidebarItems();
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 LclChapter).chapter === "number" &&
(content as LclChapter).chapter !== null;
const chapterNum = isChapter ? (content as LclChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/lcl"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Life Comes from Life
</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 */}
<LclSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<LclNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<LclNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function LclSidebar({
items,
currentSlug,
}: {
items: LclSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters (about-the-author)
const frontMatterItems: LclSidebarItem[] = [];
const chapterItems: LclSidebarItem[] = [];
const backMatterItems: LclSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.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>
</>
)}
{/* Back Matter (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: LclSidebarItem;
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/lcl/${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 LclNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: LclSidebarItem | null;
next: LclSidebarItem | null;
sidebarItems: LclSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: LclSidebarItem[] = [];
const chapterItems: LclSidebarItem[] = [];
const backMatterItems: LclSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Life Comes from Life"
/>
{prev ? (
<Link
href={`/books/lcl/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/lcl/${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>
);
}
+136
View File
@@ -0,0 +1,136 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLclMeta, getLclIntroSegments } from "@/lib/lcl";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function LclPage() {
const meta = await getLclMeta();
if (!meta) notFound();
const introSegments = await getLclIntroSegments();
// 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/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lcl/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* 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/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+312
View File
@@ -0,0 +1,312 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getLobContent,
getLobSidebarItems,
type LobChapter,
type LobSidebarItem,
} from "@/lib/lob";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function LobChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getLobContent(slug);
if (!content) notFound();
const sidebarItems = await getLobSidebarItems();
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 LobChapter).chapter === "number" &&
(content as LobChapter).chapter !== null;
const chapterNum = isChapter ? (content as LobChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/lob"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Light of the Bh&#257;gavata
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Text {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<LobSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<LobNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
paragraph.startsWith("<b>") ? (
<div
key={i}
className="text-base text-foreground leading-relaxed font-medium bg-muted/30 rounded-lg p-4 border border-border/50"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
) : (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
)
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<LobNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function LobSidebar({
items,
currentSlug,
}: {
items: LobSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Texts: chapter items
// (LoB has no back matter)
const frontMatterItems: LobSidebarItem[] = [];
const textItems: LobSidebarItem[] = [];
for (const item of items) {
if (item.type === "chapter") {
textItems.push(item);
} else {
frontMatterItems.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>
</>
)}
{/* 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>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: LobSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Format: "N. Text N"
label = `${item.chapter}. Text ${item.chapter}`;
} else {
label = item.label;
}
return (
<Link
href={`/books/lob/${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 LobNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: LobSidebarItem | null;
next: LobSidebarItem | null;
sidebarItems: LobSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: LobSidebarItem[] = [];
const textItems: LobSidebarItem[] = [];
for (const item of sidebarItems) {
if (item.type === "chapter") {
textItems.push(item);
} else {
frontMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lob/${i.slug}`,
type: i.type,
})),
});
}
if (textItems.length > 0) {
mobileNavGroups.push({
title: "Texts",
items: textItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lob/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Light of the Bhāgavata"
/>
{prev ? (
<Link
href={`/books/lob/${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
? `Text ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/lob/${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
? `Text ${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 { getLobMeta, getLobIntroSegments } from "@/lib/lob";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function LobPage() {
const meta = await getLobMeta();
if (!meta) notFound();
const introSegments = await getLobIntroSegments();
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.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/lob/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* 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.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lob/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
type MmMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("mm", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("mm", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `mm-${chapter}-${verse}`,
book: "mm",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const mmMeta = await getMetaWithFallback();
if (!mmMeta) notFound();
const chapterInfo = mmMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/mm/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Mukunda-mālā-stotra
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="mm"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/mm/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export default async function MmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(MM_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Mukunda-mālā-stotra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("mm", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/mm/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/mm/${chapter}/${verseIndex[0].verse}`);
}
+158
View File
@@ -0,0 +1,158 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
type MmMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type MmIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
async function getMetaWithFallback(): Promise<MmMeta | null> {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): MmIntroSegment[] {
const introDir = path.join(MM_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: MmIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as MmIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function MmPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
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>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{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">
<li>
<Link
href="/books/mm/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.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 py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
type NbsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("nbs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("nbs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `nbs-${chapter}-${verse}`,
book: "nbs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const nbsMeta = await getMetaWithFallback();
if (!nbsMeta) notFound();
const chapterInfo = nbsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/nbs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Nārada-bhakti-sūtra
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="nbs"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/nbs/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export default async function NbsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(NBS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Nārada-bhakti-sūtra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("nbs", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/nbs/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/nbs/${chapter}/${verseIndex[0].verse}`);
}
+158
View File
@@ -0,0 +1,158 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
type NbsMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type NbsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
async function getMetaWithFallback(): Promise<NbsMeta | null> {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): NbsIntroSegment[] {
const introDir = path.join(NBS_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: NbsIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as NbsIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function NbsPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
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>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{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">
<li>
<Link
href="/books/nbs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.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 py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nbs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+336
View File
@@ -0,0 +1,336 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getSvaContent,
getSvaSidebarItems,
type SvaChapter,
type SvaSidebarItem,
} from "@/lib/sva";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function SvaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getSvaContent(slug);
if (!content) notFound();
const sidebarItems = await getSvaSidebarItems();
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 SvaChapter).chapter === "number" &&
(content as SvaChapter).chapter !== null;
const chapterNum = isChapter ? (content as SvaChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/sva"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Songs of the Vaiṣṇava Ācāryas
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Part {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-6xl mx-auto flex">
{/* Left sidebar */}
<SvaSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<SvaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<SvaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function SvaSidebar({
items,
currentSlug,
}: {
items: SvaSidebarItem[];
currentSlug: string;
}) {
const frontMatterItems: SvaSidebarItem[] = [];
const chapterItems: SvaSidebarItem[] = [];
const backMatterItems: SvaSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.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>
</>
)}
{/* Parts */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Parts
</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>
</>
)}
{/* Back Matter */}
{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: SvaSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Part N: "
const titlePart = item.label.replace(/^Part \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return (
<Link
href={`/books/sva/${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 SvaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: SvaSidebarItem | null;
next: SvaSidebarItem | null;
sidebarItems: SvaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: SvaSidebarItem[] = [];
const chapterItems: SvaSidebarItem[] = [];
const backMatterItems: SvaSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Parts",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Songs of the Vaiṣṇava Ācāryas"
/>
{prev ? (
<Link
href={`/books/sva/${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
? `Part ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/sva/${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
? `Part ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSvaMeta, getSvaIntroSegments } from "@/lib/sva";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function SvaPage() {
const meta = await getSvaMeta();
if (!meta) notFound();
const introSegments = await getSvaIntroSegments();
// 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/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters (Parts) */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Parts
</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/sva/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Part {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{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/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTlkContent,
getTlkSidebarItems,
type TlkChapter,
type TlkSidebarItem,
} from "@/lib/tlk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TlkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTlkContent(slug);
if (!content) notFound();
const sidebarItems = await getTlkSidebarItems();
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 TlkChapter).chapter === "number" &&
(content as TlkChapter).chapter !== null;
const chapterNum = isChapter ? (content as TlkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/tlk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Kapila
</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 */}
<TlkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TlkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TlkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TlkSidebar({
items,
currentSlug,
}: {
items: TlkSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: TlkSidebarItem[] = [];
const chapterItems: TlkSidebarItem[] = [];
const backMatterItems: TlkSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.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>
</>
)}
{/* Back Matter */}
{backMatterItems.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">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TlkSidebarItem;
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/tlk/${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 TlkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TlkSidebarItem | null;
next: TlkSidebarItem | null;
sidebarItems: TlkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: TlkSidebarItem[] = [];
const chapterItems: TlkSidebarItem[] = [];
const backMatterItems: TlkSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Lord Kapila"
/>
{prev ? (
<Link
href={`/books/tlk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tlk/${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>
);
}
+136
View File
@@ -0,0 +1,136 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTlkMeta, getTlkIntroSegments } from "@/lib/tlk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function TlkPage() {
const meta = await getTlkMeta();
if (!meta) notFound();
const introSegments = await getTlkIntroSegments();
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
const frontMatter = introSegments.filter((seg) => seg.order < 50);
const backMatter = introSegments.filter((seg) => seg.order >= 50);
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/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{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/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTqkContent,
getTqkSidebarItems,
type TqkChapter,
type TqkSidebarItem,
} from "@/lib/tqk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TqkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTqkContent(slug);
if (!content) notFound();
const sidebarItems = await getTqkSidebarItems();
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 TqkChapter).chapter === "number" &&
(content as TqkChapter).chapter !== null;
const chapterNum = isChapter ? (content as TqkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/tqk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Queen Kuntī
</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 */}
<TqkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TqkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TqkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TqkSidebar({
items,
currentSlug,
}: {
items: TqkSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: TqkSidebarItem[] = [];
const chapterItems: TqkSidebarItem[] = [];
const backMatterItems: TqkSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.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>
</>
)}
{/* Back Matter */}
{backMatterItems.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">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TqkSidebarItem;
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/tqk/${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 TqkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TqkSidebarItem | null;
next: TqkSidebarItem | null;
sidebarItems: TqkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: TqkSidebarItem[] = [];
const chapterItems: TqkSidebarItem[] = [];
const backMatterItems: TqkSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Queen Kuntī"
/>
{prev ? (
<Link
href={`/books/tqk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tqk/${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>
);
}
+136
View File
@@ -0,0 +1,136 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTqkMeta, getTqkIntroSegments } from "@/lib/tqk";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export default async function TqkPage() {
const meta = await getTqkMeta();
if (!meta) notFound();
const introSegments = await getTqkIntroSegments();
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
const frontMatter = introSegments.filter((seg) => seg.order < 50);
const backMatter = introSegments.filter((seg) => seg.order >= 50);
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/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tqk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{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/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+357
View File
@@ -0,0 +1,357 @@
/**
* Data access layer for Śrī Brahma-saṁhitā (BS).
*
* Reads from MongoDB via Prisma.
* BS has BOTH verses (in the `verses` collection)
* AND prose intro segments (in the `prose_chapters` collection).
*
* Verses were seeded with:
* - book: "bs", division: null
* - chapter = text number (1-62)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "bs", volume: 0, type: "intro-segment"
* - slugs like "intro-introduction", "intro-foreword"
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BOOK = "bs";
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Text title lookup
// ---------------------------------------------------------------------------
const TEXT_TITLES: Record<number, string> = {
1: "Text 1",
2: "Text 2",
3: "Text 3",
4: "Text 4",
5: "Text 5",
6: "Text 6",
7: "Text 7",
8: "Text 8",
9: "Text 9",
10: "Text 10",
11: "Text 11",
12: "Text 12",
13: "Text 13",
14: "Text 14",
15: "Text 15",
16: "Text 16",
17: "Text 17",
18: "Text 18",
19: "Text 19",
20: "Text 20",
21: "Text 21",
22: "Text 22",
23: "Text 23",
24: "Text 24",
25: "Text 25",
26: "Text 26",
27: "Text 27",
28: "Text 28",
29: "Text 29",
30: "Text 30",
31: "Text 31",
32: "Text 32",
33: "Text 33",
34: "Text 34",
35: "Text 35",
36: "Text 36",
37: "Text 37",
38: "Text 38",
39: "Text 39",
40: "Text 40",
41: "Text 41",
42: "Text 42",
43: "Text 43",
44: "Text 44",
45: "Text 45",
46: "Text 46",
47: "Text 47",
48: "Text 48",
49: "Text 49",
50: "Text 50",
51: "Text 51",
52: "Text 52",
53: "Text 53",
54: "Text 54",
55: "Text 55",
56: "Text 56",
57: "Text 57",
58: "Text 58",
59: "Text 59",
60: "Text 60",
61: "Text 61",
62: "Text 62",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BsMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: BsTextEntry[];
};
export type BsTextEntry = {
number: number;
title: string;
slug: string;
};
export type BsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type BsVerse = {
book: string;
book_title: string;
text: number; // 1-62
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 BsSidebarItem = {
/** URL-safe slug: "intro-introduction", "intro-foreword", "1" … "62" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Text number for verses, or null for intro segments */
text: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/iso.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, texts list, etc.)
*/
export async function getBsMeta(): Promise<BsMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as BsMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getBsIntroSegments(): Promise<BsIntroSegment[]> {
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 getBsIntroSegment(
slug: string,
): Promise<BsIntroSegment | 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/text by slug.
*
* - "1"-"62" → chapter=1–62, verse=1
*
* Verse stored as chapter=text_number, verse=1 in the verses collection.
*/
export async function getBsVerse(slug: string): Promise<BsVerse | null> {
const num = Number(slug);
if (!Number.isInteger(num) || num < 1 || num > 62) return null;
const textNum = num;
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-introduction" → intro segment
* "intro-foreword" → intro segment
* "1" … "62" → verse (text)
*/
export async function getBsContent(
slug: string,
): Promise<BsVerse | BsIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getBsIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 62) {
return getBsVerse(String(num));
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: Introduction → Foreword → Texts 1-62
*/
export async function getBsSidebarItems(): Promise<BsSidebarItem[]> {
const items: BsSidebarItem[] = [];
// 1. Introduction intro segment
items.push({
slug: "intro-introduction",
label: "Introduction",
type: "intro-segment",
text: null,
});
// 2. Foreword intro segment
items.push({
slug: "intro-foreword",
label: "Foreword",
type: "intro-segment",
text: null,
});
// 3. Texts 1-62 (verses)
for (let i = 1; i <= 62; i++) {
items.push({
slug: String(i),
label: `${i}. ${TEXT_TITLES[i]}`,
type: "verse",
text: i,
});
}
return items;
}
+443
View File
@@ -0,0 +1,443 @@
/**
* Data access layer for Gītār Gān (GG).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* GG is a prose/poetry book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/gg/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "gg";
const GG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "gg");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type GgMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: GgChapterEntry[];
};
export type GgChapterEntry = {
number: number;
title: string;
};
export type GgIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type GgChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type GgSidebarItem = {
/** URL-safe slug: "intro-introduction", "1", "18" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): GgIntroSegment[] {
const introDir = path.join(GG_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: GgIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<GgIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<GgIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): GgChapter | null {
const filePath = path.join(
GG_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<GgChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getGgMeta(): Promise<GgMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as GgMeta;
// Fallback to local JSON
const introPath = path.join(GG_JSON_DIR, "intro.json");
return readLocalJson<GgMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getGgIntroSegments(): Promise<GgIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getGgIntroSegment(
slug: string,
): Promise<GgIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–18).
*/
export async function getGgChapter(
chapterNum: number,
): Promise<GgChapter | null> {
if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-introduction" → intro segment
* "1" … "18" → chapter
*/
export async function getGgContent(
slug: string,
): Promise<GgChapter | GgIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getGgIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getGgChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: introduction → chapters 1–18
*/
export async function getGgSidebarItems(): Promise<GgSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getGgMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: GgMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): GgSidebarItem[] {
const items: GgSidebarItem[] = [];
// 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–18 (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,
});
}
// Intro segments that come AFTER 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,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: GgMeta | null): GgSidebarItem[] {
const items: GgSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-18
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(GG_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<GgChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
}
+445
View File
@@ -0,0 +1,445 @@
/**
* Data access layer for Life Comes from Life (LCL).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* LCL is a prose/dialogue book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/lcl/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "lcl";
const LCL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lcl");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LclMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LclChapterEntry[];
};
export type LclChapterEntry = {
number: number;
title: string;
};
export type LclIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LclChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LclSidebarItem = {
/** URL-safe slug: "intro-foreword", "1", "16", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): LclIntroSegment[] {
const introDir = path.join(LCL_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LclIntroSegment[] = [];
const files = ["foreword.json", "introduction.json", "about-the-author.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LclIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LclIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LclChapter | null {
const filePath = path.join(
LCL_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LclChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLclMeta(): Promise<LclMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LclMeta;
// Fallback to local JSON
const introPath = path.join(LCL_JSON_DIR, "intro.json");
return readLocalJson<LclMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLclIntroSegments(): Promise<LclIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
*/
export async function getLclIntroSegment(
slug: string,
): Promise<LclIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–16).
*/
export async function getLclChapter(
chapterNum: number,
): Promise<LclChapter | null> {
if (chapterNum < 1 || chapterNum > 16 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-foreword" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "16" → chapter
*/
export async function getLclContent(
slug: string,
): Promise<LclChapter | LclIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLclIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 16) {
return getLclChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: foreword → introduction → chapters 1–16 → about-the-author
*/
export async function getLclSidebarItems(): Promise<LclSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLclMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: LclMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// 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–16 (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,
});
}
// 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;
}
function buildSidebarFromLocal(meta: LclMeta | null): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-16
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(LCL_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<LclChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter (about-the-author)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
}
+429
View File
@@ -0,0 +1,429 @@
/**
* Data access layer for Light of the Bhāgavata (LoB).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* LoB is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* It has 48 "texts" (numbered chapters) plus 4 intro segments
* (front-pages, preface, acknowledgement, the-artist).
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/lob/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "lob";
const LOB_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lob");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LobMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LobChapterEntry[];
};
export type LobChapterEntry = {
number: number;
title: string;
};
export type LobIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LobChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LobSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "48", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): LobIntroSegment[] {
const introDir = path.join(LOB_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LobIntroSegment[] = [];
const files = [
"front-pages.json",
"preface.json",
"acknowledgement.json",
"the-artist.json",
];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LobIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LobIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LobChapter | null {
const filePath = path.join(
LOB_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LobChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLobMeta(): Promise<LobMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LobMeta;
// Fallback to local JSON
const introPath = path.join(LOB_JSON_DIR, "intro.json");
return readLocalJson<LobMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLobIntroSegments(): Promise<LobIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getLobIntroSegment(
slug: string,
): Promise<LobIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters (Texts 1–48)
// ---------------------------------------------------------------------------
/**
* A numbered text/chapter (1–48).
*/
export async function getLobChapter(
chapterNum: number,
): Promise<LobChapter | null> {
if (chapterNum < 1 || chapterNum > 48 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "intro-acknowledgement" → intro segment
* "intro-the-artist" → intro segment
* "1" … "48" → chapter (text)
*/
export async function getLobContent(
slug: string,
): Promise<LobChapter | LobIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLobIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 48) {
return getLobChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → acknowledgement → the-artist → texts 1–48
*/
export async function getLobSidebarItems(): Promise<LobSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLobMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: LobMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LobSidebarItem[] {
const items: LobSidebarItem[] = [];
// Intro segments (all come before chapters; LoB has no back matter)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Texts 1–48 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Text ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: LobMeta | null): LobSidebarItem[] {
const items: LobSidebarItem[] = [];
// Local intro segments (front matter only — LoB has no back matter)
const introSegs = getLocalIntroSegments();
for (const seg of introSegs) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Texts from meta or generate default 1-48
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Text ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(LOB_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<LobChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Text ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
}
+445
View File
@@ -0,0 +1,445 @@
/**
* Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* SVA is a prose/song book with 4 parts (chapters) plus intro segments.
* We use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/sva/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "sva";
const SVA_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "sva");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SvaMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: SvaChapterEntry[];
};
export type SvaChapterEntry = {
number: number;
title: string;
};
export type SvaIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type SvaChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type SvaSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-foreword", "1", "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 (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): SvaIntroSegment[] {
const introDir = path.join(SVA_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: SvaIntroSegment[] = [];
const files = ["front-pages.json", "foreword.json", "introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<SvaIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): SvaIntroSegment | null {
const filePath = path.join(SVA_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<SvaIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): SvaChapter | null {
const filePath = path.join(
SVA_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<SvaChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getSvaMeta(): Promise<SvaMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as SvaMeta;
// Fallback to local JSON
const introPath = path.join(SVA_JSON_DIR, "intro.json");
return readLocalJson<SvaMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getSvaIntroSegments(): Promise<SvaIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
*/
export async function getSvaIntroSegment(
slug: string,
): Promise<SvaIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getSvaChapter(
chapterNum: number,
): Promise<SvaChapter | null> {
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-foreword" → intro segment
* "intro-introduction" → intro segment
* "1" … "4" → chapter (part)
*/
export async function getSvaContent(
slug: string,
): Promise<SvaChapter | SvaIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getSvaIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getSvaChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → foreword → introduction → parts 1–4
*/
export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getSvaMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: SvaMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): SvaSidebarItem[] {
const items: SvaSidebarItem[] = [];
// 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–4 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Part ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
// Intro segments that come AFTER 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,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: SvaMeta | null): SvaSidebarItem[] {
const items: SvaSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-4
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Part ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(SVA_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<SvaChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Part ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
}
+453
View File
@@ -0,0 +1,453 @@
/**
* Data access layer for Teachings of Lord Kapila (TLK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* TLK is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/tlk/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "tlk";
const TLK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "tlk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TlkMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: TlkChapterEntry[];
};
export type TlkChapterEntry = {
number: number;
title: string;
};
export type TlkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TlkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TlkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "18", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): TlkIntroSegment[] {
const introDir = path.join(TLK_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: TlkIntroSegment[] = [];
const files = [
"front-pages.json",
"foreword.json",
"about-the-author.json",
"credits.json",
"back-flap.json",
];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<TlkIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): TlkIntroSegment | null {
const filePath = path.join(TLK_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<TlkIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): TlkChapter | null {
const filePath = path.join(
TLK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<TlkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getTlkMeta(): Promise<TlkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as TlkMeta;
// Fallback to local JSON
const introPath = path.join(TLK_JSON_DIR, "intro.json");
return readLocalJson<TlkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTlkIntroSegments(): Promise<TlkIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
*/
export async function getTlkIntroSegment(
slug: string,
): Promise<TlkIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–18).
*/
export async function getTlkChapter(
chapterNum: number,
): Promise<TlkChapter | null> {
if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-foreword" → intro segment
* "intro-about-the-author" → intro segment
* "intro-credits" → intro segment
* "intro-back-flap" → intro segment
* "1" … "18" → chapter
*/
export async function getTlkContent(
slug: string,
): Promise<TlkChapter | TlkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTlkIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getTlkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → foreword → chapters 1–18 → about-the-author → credits → back-flap
*/
export async function getTlkSidebarItems(): Promise<TlkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTlkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: TlkMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): TlkSidebarItem[] {
const items: TlkSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 50)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–18 (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,
});
}
// Intro segments that come AFTER chapters (order >= 50, e.g. about-the-author, credits, back-flap)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: TlkMeta | null): TlkSidebarItem[] {
const items: TlkSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-18
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(TLK_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<TlkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter (about-the-author, credits, back-flap)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
}
+449
View File
@@ -0,0 +1,449 @@
/**
* Data access layer for Teachings of Queen Kuntī (TQK).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* TQK is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/tqk/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "tqk";
const TQK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "tqk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TqkMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: TqkChapterEntry[];
};
export type TqkChapterEntry = {
number: number;
title: string;
};
export type TqkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type TqkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type TqkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "26", "intro-back-flap" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): TqkIntroSegment[] {
const introDir = path.join(TQK_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: TqkIntroSegment[] = [];
const files = [
"front-pages.json",
"introduction.json",
"back-flap.json",
];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<TqkIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): TqkIntroSegment | null {
const filePath = path.join(TQK_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<TqkIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): TqkChapter | null {
const filePath = path.join(
TQK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<TqkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getTqkMeta(): Promise<TqkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as TqkMeta;
// Fallback to local JSON
const introPath = path.join(TQK_JSON_DIR, "intro.json");
return readLocalJson<TqkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getTqkIntroSegments(): Promise<TqkIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getTqkIntroSegment(
slug: string,
): Promise<TqkIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–26).
*/
export async function getTqkChapter(
chapterNum: number,
): Promise<TqkChapter | null> {
if (chapterNum < 1 || chapterNum > 26 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-back-flap" → intro segment
* "1" … "26" → chapter
*/
export async function getTqkContent(
slug: string,
): Promise<TqkChapter | TqkIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getTqkIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 26) {
return getTqkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–26 → back-flap
*/
export async function getTqkSidebarItems(): Promise<TqkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getTqkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: TqkMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): TqkSidebarItem[] {
const items: TqkSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 50)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–26 (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,
});
}
// Intro segments that come AFTER chapters (order >= 50, e.g. back-flap)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: TqkMeta | null): TqkSidebarItem[] {
const items: TqkSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-26
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(TQK_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<TqkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter (back-flap)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
}
+815
View File
@@ -0,0 +1,815 @@
#!/usr/bin/env python3
"""Extract Śrī Brahma-saṁhitā 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 = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
# --- Text number to title mapping ---
TEXT_TITLES = {n: f"Text {n}" for n in range(1, 63)}
# --- CSS class constants ---
BOOK_HEADER_CLASS = "p12"
VERSE_BOOKMARK_CLASS = "p49"
VERSE_HEADING_CLASS = "p50"
TEXT_LABEL_CLASS = "p52"
PADA_TRANSLIT_CLASS = "p5"
PADA_TRANSLIT_CONT_CLASS = "p66"
SYNONYMS_HEADER_CLASS = "p165"
SYNONYM_TEXT_CLASS = "p54"
TRANSLATION_LABEL_CLASS = "p55"
TRANSLATION_TEXT_CLASS = "p56"
PURPORT_LABEL_CLASS_ALT = "p57"
PURPORT_BODY_CLASS = "p48"
CROSS_REF_CLASS = "p58"
QUOTED_VERSE_CLASS = "p53"
COMMENTATOR_CLASS = "p44"
SECTION_HEADING_CLASS = "p31"
INTRO_BODY_CLASS = "p18"
FOREWORD_BODY_CLASS = "p6"
SIGNATURE_CLASS = "p164"
END_MARKER_CLASS = "p166"
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:
"""Combine pādas into 2-line verse format.
If the number of pādas is odd, the first one is treated as a
standalone line (e.g. speaker attribution like 'uvāca').
Empty strings in the list act as stanza-break sentinels and are
rendered as blank lines in the output.
"""
if not padas:
return ""
# Split pādas into stanza groups separated by "" sentinels
groups: list[list[str]] = [[]]
for p in padas:
if p == "":
groups.append([])
else:
groups[-1].append(p)
result_lines = []
for group in groups:
if not group:
continue
lines = []
i = 0
# If odd number of pādas, first one is standalone (speaker attribution)
if len(group) % 2 == 1:
lines.append(group[0])
i = 1
# Pair remaining pādas
while i < len(group) - 1:
lines.append(group[i] + " " + group[i + 1])
i += 2
# Handle any trailing single pāda
if i < len(group):
lines.append(group[i])
result_lines.append("\n".join(lines))
return "\n\n".join(result_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 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}")
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_bs_boundaries(all_paragraphs: list) -> tuple[int, int]:
"""Find the start and end paragraph indices of the Brahma-saṁhitā content.
Start: <p class="p12"> containing "Brahma-sa" (ScaGoudy encoded)
End: next <p class="p166"> after start (start of next book)
"""
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 and start_idx is None:
text = p.get_text().strip()
# ScaGoudy-encoded: "Çré Brahma-saàhitä"
if "Brahma-sa" in text:
start_idx = i
continue
if start_idx is not None and cls == END_MARKER_CLASS:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Śrī Brahma-saṁhitā' header in other.html!")
sys.exit(1)
return start_idx, end_idx
def parse_verse_bookmark(text: str) -> int | None:
"""Parse a verse bookmark like 'Bs 5.N'.
Returns text number (1-62), or None.
"""
text = text.strip()
m = re.match(r"Bs\s+5\.(\d+)", text)
if m:
return int(m.group(1))
return None
# ---------------------------------------------------------------------------
# Section boundary detection within BS range
# ---------------------------------------------------------------------------
def find_section_boundaries(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
"""Find key section boundaries within the BS content.
Returns dict with section names and their start indices.
"""
sections = {}
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
text = p.get_text().strip()
# Introduction bookmark
if cls == VERSE_BOOKMARK_CLASS and "Bs Introduction" in text:
sections["introduction_bookmark"] = i
# Foreword bookmark
elif cls == VERSE_BOOKMARK_CLASS and "Bs Foreword" in text:
sections["foreword_bookmark"] = i
# First verse bookmark (Bs 5.1)
elif cls == VERSE_BOOKMARK_CLASS and parse_verse_bookmark(decode_text(p)) == 1:
sections["first_verse"] = i
break
# Find the chapter heading (p31 with "Chapter Five")
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == SECTION_HEADING_CLASS:
text = p.get_text().strip()
if "Chapter Five" in text or "Chapter" in text:
# Check if this is the chapter title, not intro/foreword headers
if i > sections.get("foreword_bookmark", 0):
sections["chapter_heading"] = i
break
return sections
# ---------------------------------------------------------------------------
# Intro segment extraction
# ---------------------------------------------------------------------------
def extract_intro_segment(
all_paragraphs: list, start_idx: int, end_idx: int, body_classes: tuple
) -> list[str]:
"""Extract paragraphs for an intro segment.
Decodes ScaGoudy, preserves HTML formatting, skips 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 (VERSE_BOOKMARK_CLASS, BOOK_HEADER_CLASS, SECTION_HEADING_CLASS):
continue
# Include specified body classes and signature class
if cls in body_classes or cls == SIGNATURE_CLASS:
html = decode_html_content(p)
if html:
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 BS Chapter Five section using a state machine.
States: IDLE → VERSE_START → TRANSLITERATION → SYNONYMS →
TRANSLATION → POST_TRANSLATION → PURPORT → (back to IDLE)
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)
verse_num = parse_verse_bookmark(text)
if verse_num is not None:
reference = f"Bs 5.{verse_num}"
current_verse = {
"book_title": "Śrī Brahma-saṁhitā",
"chapter": 5,
"verse": verse_num,
"reference": reference,
"devanagari": "",
"transliteration": "",
"_padas": [],
"synonyms": [],
"translation": "",
"purport": [],
}
state = "VERSE_START"
continue
if not current_verse:
continue
# --- Verse heading (p50) - just confirms the text number ---
if cls == VERSE_HEADING_CLASS:
continue
# --- TEXT label (p52) - transition to transliteration ---
if cls == TEXT_LABEL_CLASS:
state = "TRANSLITERATION"
continue
# --- Pāda transliteration lines (p5 and p66) ---
if cls in (PADA_TRANSLIT_CLASS, PADA_TRANSLIT_CONT_CLASS) and state in (
"VERSE_START",
"TRANSLITERATION",
):
state = "TRANSLITERATION"
line = decode_text(p)
# Remove trailing period
line = line.rstrip(".")
line = line.strip()
if line:
# p66 marks the start of a new stanza group — insert a blank
# sentinel so _finalize_verse preserves the line break.
if cls == PADA_TRANSLIT_CONT_CLASS and current_verse["_padas"]:
current_verse["_padas"].append("")
current_verse["_padas"].append(line)
continue
# --- SYNONYMS header (p165) ---
if cls == SYNONYMS_HEADER_CLASS:
state = "SYNONYMS"
continue
# --- Synonyms text (p54) ---
if cls == SYNONYM_TEXT_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 label (p55) — check text content to distinguish from PURPORT ---
if cls == TRANSLATION_LABEL_CLASS and current_verse:
inner_text = p.get_text().strip().upper()
if "TRANSLATION" in inner_text:
state = "TRANSLATION"
elif "PURPORT" in inner_text:
state = "PURPORT"
continue
# --- PURPORT label via p57 ---
if cls == PURPORT_LABEL_CLASS_ALT and current_verse:
inner_text = p.get_text().strip().upper()
if "PURPORT" in inner_text:
state = "PURPORT"
elif "TRANSLATION" in inner_text:
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)
html = html.strip()
current_verse["translation"] = html
state = "POST_TRANSLATION"
continue
# --- Purport paragraphs (p48, p6) ---
if (
cls in (PURPORT_BODY_CLASS, FOREWORD_BODY_CLASS)
and current_verse
and state in ("PURPORT", "POST_TRANSLATION")
):
state = "PURPORT"
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Cross-references (p58) — treat as purport paragraph ---
if (
cls == CROSS_REF_CLASS
and current_verse
and state in ("PURPORT", "POST_TRANSLATION")
):
state = "PURPORT"
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Quoted verse in purport (p53) — treat as purport paragraph ---
if cls == QUOTED_VERSE_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Commentator text in purport (p44) — treat as purport paragraph ---
if cls == COMMENTATOR_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Section heading within purport (p31) ---
if cls == SECTION_HEADING_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
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, blank sentinels become blank lines
trans_lines = []
for p in padas:
if p == "":
trans_lines.append("") # blank line between stanza groups
else:
trans_lines.append(p)
verse["transliteration"] = "\n".join(trans_lines)
# 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"]
# ---------------------------------------------------------------------------
# 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", "bs")
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 BS boundaries ---
bs_start, bs_end = find_bs_boundaries(all_paragraphs)
print(f"BS content: paragraphs {bs_start} to {bs_end}")
# --- Find section boundaries ---
sections = find_section_boundaries(all_paragraphs, bs_start, bs_end)
print(f"\nSection boundaries found:")
for name, idx in sorted(sections.items(), key=lambda x: x[1]):
print(f" [{idx}] {name}")
intro_bm = sections.get("introduction_bookmark")
foreword_bm = sections.get("foreword_bookmark")
chapter_heading = sections.get("chapter_heading")
first_verse = sections.get("first_verse")
if first_verse is None:
print("ERROR: Could not find first verse bookmark (Bs 5.1)!")
sys.exit(1)
# --- Extract intro segments ---
print("\n--- Extracting Intro Segments ---")
intro_segments = []
# 1. Introduction segment
if intro_bm is not None:
intro_end = foreword_bm if foreword_bm is not None else first_verse
intro_paras = extract_intro_segment(
all_paragraphs, intro_bm, intro_end, (INTRO_BODY_CLASS,)
)
intro_segment = {
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "bs",
"order": 1,
"paragraphs": intro_paras,
}
intro_segments.append(intro_segment)
print(f" Introduction: {len(intro_paras)} paragraphs")
# 2. Foreword segment
if foreword_bm is not None:
foreword_end = chapter_heading if chapter_heading is not None else first_verse
foreword_paras = extract_intro_segment(
all_paragraphs, foreword_bm, foreword_end, (FOREWORD_BODY_CLASS,)
)
foreword_segment = {
"slug": "foreword",
"title": "Foreword",
"type": "intro-segment",
"book": "bs",
"order": 2,
"paragraphs": foreword_paras,
}
intro_segments.append(foreword_segment)
print(f" Foreword: {len(foreword_paras)} paragraphs")
# --- Extract verses ---
print("\n--- Extracting Verses ---")
# Verse section runs from the first verse bookmark to bs_end
verses = extract_verses(all_paragraphs, first_verse, bs_end)
print(f"Extracted {len(verses)} verses (expected 62: Texts 1-62)")
# --- Write intro.json ---
intro_meta = {
"book": "bs",
"title": "Śrī Brahma-saṁhitā",
"author": "Śrīla Bhaktisiddhānta Sarasvatī Ṭhākura",
"year": 1932,
"description": "Chapter Five of the Hymns of Brahmā, discovered by Śrī Caitanya Mahāprabhu. Commentary by Śrīla Bhaktisiddhānta Sarasvatī Ṭhākura.",
"totalChapters": 1,
"chapters": [
{
"number": 5,
"title": "Chapter Five",
"totalVerses": 62,
}
],
}
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["verse"]
filename = f"{str(text_num).zfill(2)}.json"
filepath = os.path.join(output_base, "chapters", "05", "verses", filename)
write_json(verse, filepath)
# --- Summary ---
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Book: Śrī Brahma-saṁhitā")
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)}")
texts_found = sorted([v["verse"] 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")
# Not all verses necessarily have purport, but most do
if not verse["purport"]:
issues.append(f" {ref}: missing purport (may be intentional)")
if issues:
print(" ISSUES FOUND:")
for issue in issues:
print(issue)
else:
print(
" All verses have transliteration, devanagari, synonyms, and translation ✓"
)
# Check expected verse count
expected_texts = set(range(1, 63))
found_texts = set(v["verse"] for v in verses)
missing = expected_texts - found_texts
extra = found_texts - expected_texts
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 62 texts found ✓")
# --- Samples ---
print(f"\n--- Sample: Text 1 (first) ---")
t1 = [v for v in verses if v["verse"] == 1]
if t1:
v = t1[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: Text 29 (middle) ---")
t29 = [v for v in verses if v["verse"] == 29]
if t29:
v = t29[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: Text 62 (last) ---")
t62 = [v for v in verses if v["verse"] == 62]
if t62:
v = t62[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'])}")
# --- Intro segment paragraph counts ---
print(f"\n--- Intro Segment Details ---")
for seg in intro_segments:
print(f" {seg['title']}: {len(seg['paragraphs'])} paragraphs")
if seg["paragraphs"]:
first_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][0])[:100]
print(f" First ¶: {first_preview}...")
last_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][-1])[:100]
print(f" Last ¶: {last_preview}...")
if __name__ == "__main__":
main()
+845
View File
@@ -0,0 +1,845 @@
#!/usr/bin/env python3
"""Extract Gītār Gān (GG) from other.html.
Gītār Gān is a Bengali poetic rendering of Bhagavad-gītā As It Is.
It has 1 intro segment (introduction) and 18 chapters.
The content is primarily Bengali verse text (p24, italic with <br> line breaks)
interleaved with text number headings (p224, bold).
Output:
lib/jsons/books/gg/intro.json — Book metadata
lib/jsons/books/gg/intro/*.json — Intro segments
lib/jsons/books/gg/chapters/*.json — Chapters (01–18)
Usage:
python3 scripts/gg_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)
# First, protect <br> tags
inner = re.sub(r"<br\s*/?>", "<br/>", inner)
# Normalize spaces around text (not across <br>)
parts = inner.split("<br/>")
parts = [re.sub(r"\s+", " ", p).strip() for p in parts]
inner = "<br/>".join(parts)
return inner.strip()
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 GG
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Subtitle class
SUBTITLE_CLASS = "p31"
# Bookmark class
BOOKMARK_CLASS = "p13"
# Introduction heading / Chapter heading
HEADING_CLASS = "p27"
# Introduction body text
BODY_CLASS = "p6"
# Signature block
SIGNATURE_CLASS = "p222"
# Chapter title heading (contains Chapter N + Title)
CHAPTER_TITLE_CLASS = "p223"
# Bengali verse text (italic, with <br> line breaks — THE MAIN content)
VERSE_CLASS = "p24"
# Text number headings (bold)
TEXT_HEADING_CLASS = "p224"
# Empty/break classes to skip
SKIP_CLASSES = frozenset(["p225", "p226", "p15"])
# ---------------------------------------------------------------------------
# Chapter titles mapping (from bookmarks)
# ---------------------------------------------------------------------------
CHAPTER_TITLES = {
1: "Observing the Armies on the Battlefield of Kurukṣetra",
2: "Contents of the Gītā Summarized",
3: "Karma-yoga",
4: "Transcendental Knowledge",
5: "Karma-yoga—Action in Kṛṣṇa Consciousness",
6: "Dhyāna-yoga",
7: "Knowledge of the Absolute",
8: "Attaining the Supreme",
9: "The Most Confidential Knowledge",
10: "The Opulence of the Absolute",
11: "The Universal Form",
12: "Devotional Service",
13: "Nature, the Enjoyer, and Consciousness",
14: "The Three Modes of Material Nature",
15: "The Yoga of the Supreme Person",
16: "The Divine and Demoniac Natures",
17: "The Divisions of Faith",
18: "Conclusion—The Perfection of Renunciation",
}
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_gg_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the GG content.
Start: <p class="p12"><b>Gétär Gän</b></p> (or decoded: Gītār Gān)
End: Next <p class="p12"> after GG content (Songs of the Vaiṣṇava Ācāryas)
"""
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()
decoded = decode_scagoudy(text)
if cls == BOOK_HEADER_CLASS:
if start_idx is None:
# Look for "Gétär Gän" or decoded "Gītār Gān"
if (
"Gétär Gän" in text
or "Gītār Gān" in decoded
or "G\u00e9t\u00e4r G\u00e4n" in text
):
start_idx = i
continue
else:
# Next p12 after start marks the end
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Gītār Gān' 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 GG bookmark tags in the GG range.
Bookmarks are p13 class with text starting with "GG:".
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:
continue
text = decode_text(p).strip()
if text.startswith("GG:"):
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str):
"""Parse a chapter bookmark like 'GG: Chapter 1'.
Returns chapter_number (int) or None if not a chapter bookmark.
"""
m = re.match(r"GG:\s+Chapter\s+(\d+)", text.strip())
if m:
return int(m.group(1))
return None
def extract_chapter_title(tag: Tag) -> tuple:
"""Extract chapter number and title from a p223 element.
The p223 contains something like:
<span class="s41"><b>Chapter 1</b></span><b>Observing the Armies...</b>
Returns (chapter_number, title_text) or None.
"""
text = decode_text(tag)
# Try to parse "Chapter N\nTitle" or "Chapter N Title"
m = re.match(r"Chapter\s+(\d+)\s*(.*)", text, re.DOTALL)
if m:
ch_num = int(m.group(1))
title = m.group(2).strip()
# Clean up any leading/trailing whitespace or newlines
title = re.sub(r"\s+", " ", title).strip()
return ch_num, title
return None
def extract_verse_content(tag: Tag) -> str:
"""Extract Bengali verse content from a p24 tag.
The content is italic with <br> line breaks. We wrap it as a centered
italic div: <div style="text-align:center"><i>content</i></div>
ScaGoudy decoding is applied.
"""
html = decode_html_content(tag)
if not html:
return ""
# Strip outer <i> tags if present — we'll re-wrap
stripped = re.sub(r"^<i>(.*)</i>$", r"\1", html, flags=re.DOTALL)
# Also remove any remaining <i>/<\/i> pairs within
stripped = re.sub(r"</?i>", "", stripped)
if stripped.strip():
return f'<div style="text-align:center"><i>{stripped.strip()}</i></div>'
return ""
def extract_text_heading(tag: Tag) -> tuple:
"""Extract text heading from a p224 tag.
Content is bold, e.g. <b>Text 1</b> or <b>Texts 16-18</b>
or closing: <b>Thus Bhaktivedānta sings...</b>
Some headings have the translation merged in, like:
<b>Text 30: All these performers who know...</b>
These are split: the text number is returned as the heading,
and the translation is returned separately as a "deferred" paragraph
to be emitted AFTER the next verse block.
Returns (heading_html, deferred_translation_html_or_None).
"""
html = decode_html_content(tag)
if not html:
return ("", None)
stripped = html.strip()
# Remove outer <b> to inspect inner text
inner = stripped
if inner.startswith("<b>") and inner.endswith("</b>"):
inner = inner[3:-4].strip()
# Check if this is a merged "Text N: translation..." heading
m = re.match(r"^(Texts?\s+[\d\-–]+)\s*[:\.]\s*(.+)$", inner, re.DOTALL)
if m:
text_num = m.group(1).strip()
translation = m.group(2).strip()
heading = f'<div style="text-align:center"><b>{text_num}</b></div>'
deferred = f'<div style="text-align:center"><b>{translation}</b></div>'
return (heading, deferred)
# Plain text heading (e.g. "Text 1") or closing statement — center it
if not stripped.startswith("<b>"):
stripped = f"<b>{stripped}</b>"
return (f'<div style="text-align:center">{stripped}</div>', None)
def extract_chapter_heading(tag: Tag) -> str:
"""Extract chapter heading from a p223 tag as a bold paragraph.
Returns something like: <b>Chapter 1<br/>Observing the Armies...</b>
"""
html = decode_html_content(tag)
if not html:
return ""
# Clean up the HTML — remove inner bold tags and re-wrap the whole thing
inner = re.sub(r"</?b>", "", html)
inner = inner.strip()
# Try to add a <br/> between "Chapter N" and the title if not already there
inner = re.sub(r"(Chapter\s+\d+)\s+", r"\1<br/>", inner, count=1)
return f"<b>{inner}</b>"
def extract_paragraphs_for_intro(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list:
"""Extract paragraphs for the introduction section.
Handles p27 (heading), p6 (body text), p222 (signature block).
"""
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 header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip subtitle (p31)
if cls == SUBTITLE_CLASS:
continue
# Introduction heading (p27)
if cls == HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body text (p6)
if cls == BODY_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Signature block (p222)
if cls == SIGNATURE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Any other class in intro range — try to include
html = decode_html_content(p)
if html:
result.append(html)
return result
def extract_paragraphs_for_chapter(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list:
"""Extract paragraphs for a chapter.
Handles:
p223 — Chapter heading (bold paragraph)
p24 — Bengali verse text (centered italic div)
p224 — Text number heading (bold paragraph)
p225, p226, p15 — Skip (empty/break)
"""
result = []
deferred_translation = None
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
# Chapter title heading (p223)
if cls == CHAPTER_TITLE_CLASS:
heading = extract_chapter_heading(p)
if heading:
result.append(heading)
continue
# Bengali verse text (p24)
if cls == VERSE_CLASS:
verse = extract_verse_content(p)
if verse:
result.append(verse)
# Emit deferred translation AFTER the verse
if deferred_translation:
result.append(deferred_translation)
deferred_translation = None
continue
# Text number heading (p224)
if cls == TEXT_HEADING_CLASS:
# If there's a deferred translation from a previous heading,
# emit it now (before this new heading)
if deferred_translation:
result.append(deferred_translation)
deferred_translation = None
heading, deferred = extract_text_heading(p)
if heading:
result.append(heading)
if deferred:
# Hold the translation — it will be emitted after the next verse block
deferred_translation = deferred
continue
# Heading class (p27) — may appear within chapters
if cls == HEADING_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Body text (p6) — may appear within chapters
if cls == BODY_CLASS:
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)
# Flush any remaining deferred translation at end of chapter
if deferred_translation:
result.append(deferred_translation)
deferred_translation = None
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_gg(soup: BeautifulSoup) -> dict:
"""Extract the entire Gītār Gān 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 GG boundaries
gg_start, gg_end = find_gg_boundaries(all_paragraphs)
print(f" GG content: paragraphs {gg_start} to {gg_end}")
print(f" Total paragraphs in range: {gg_end - gg_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, gg_start, gg_end)
print(f" Found {len(bookmarks)} GG bookmarks")
for pos, text in bookmarks:
print(f" Para ~{pos}: {text}")
# Step 3: Categorize bookmarks
chapter_bookmarks = [] # (para_idx, chapter_num)
for pos, text in bookmarks:
ch_num = parse_chapter_bookmark(text)
if ch_num is not None:
chapter_bookmarks.append((pos, ch_num))
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
# Sort chapter bookmarks by position
chapter_bookmarks.sort(key=lambda x: x[0])
# Build an ordered list of all section start positions for boundary computation
all_section_starts = sorted([pos for pos, _ in chapter_bookmarks])
def next_section_after(pos):
"""Return the next section start after `pos`, or gg_end."""
for s in all_section_starts:
if s > pos:
return s
return gg_end
# Step 4: Extract Introduction
# The introduction is everything between the book header and the first chapter bookmark
introduction = None
if chapter_bookmarks:
first_chapter_pos = chapter_bookmarks[0][0]
# Introduction starts after the book header (gg_start + 1) and ends at first bookmark
intro_content_start = gg_start + 1
intro_content_end = first_chapter_pos
intro_paras = extract_paragraphs_for_intro(
all_paragraphs, intro_content_start, intro_content_end
)
if intro_paras:
introduction = {
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "gg",
"order": 1,
"paragraphs": intro_paras,
}
# Step 5: Extract chapters 1–18
chapters = []
for idx, (ch_pos, ch_num) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
content_end = next_section_after(ch_pos)
paragraphs = extract_paragraphs_for_chapter(
all_paragraphs, content_start, content_end
)
# Get title from CHAPTER_TITLES mapping, or try to extract from p223
title = CHAPTER_TITLES.get(ch_num, f"Chapter {ch_num}")
# Try to extract title from the first p223 in the range
for i in range(content_start, min(content_start + 10, content_end)):
p = all_paragraphs[i]
cls = get_css_class(p)
if cls == CHAPTER_TITLE_CLASS:
extracted = extract_chapter_title(p)
if extracted:
_, extracted_title = extracted
if extracted_title:
title = extracted_title
break
ch = {
"book": "gg",
"chapter": ch_num,
"title": title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 6: Build intro segments list
intro_segments = []
if introduction:
intro_segments.append(introduction)
# 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", "gg")
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 Gītār Gān...\n")
result = extract_gg(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "gg",
"title": "Gītār Gān",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1995,
"description": "A Bengali poetic rendering of Bhagavad-gītā As It Is by Śrīla Prabhupāda.",
"totalChapters": 18,
"chapters": chapter_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write intro segments ---
print(f"\n--- Intro Segments ---")
for seg in intro_segments:
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
write_json(seg, filepath)
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
)
# --- Write chapters ---
print(f"\n--- Chapters ---")
for ch in chapters:
ch_num_str = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}"
)
# --- Summary ---
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
total_paras = total_intro_paras + total_chapter_paras
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Intro segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Chapters: {len(chapters)}")
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 9:
print(f"\n--- Sample: Chapter 9 ---")
ch9 = [c for c in chapters if c["chapter"] == 9]
if ch9:
for i, p in enumerate(ch9[0]["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 18:
print(f"\n--- Sample: Chapter 18 (last) ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 18 chapters present
expected_chapters = set(range(1, 19))
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 18 chapters present")
# Check intro segments
expected_intros = {"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" ✅ Introduction segment present")
# Check empty chapters
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
if empty_chapters:
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
else:
print(f" ✅ No empty chapters")
# Check ScaGoudy decoding — look for common encoded characters
encoded_chars = set("äåöëòçüéàùè")
suspect_count = 0
all_sections = chapters + intro_segments
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")
# Check text headings
text_heading_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if re.match(r"<b>Texts?\s+\d+", p):
text_heading_count += 1
print(f" ℹ {text_heading_count} text number headings in chapters")
# ScaGoudy decoding spot check
print(f"\n--- ScaGoudy Decoding Spot Check ---")
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"][:15])
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 15 paragraphs (may be normal)"
)
if "Kurukṣetra" in ch1_text:
print(f" ✅ 'Kurukṣetra' correctly decoded in Chapter 1")
elif "Kurukñetra" in ch1_text:
print(f" ⚠ 'Kurukñetra' still encoded")
else:
print(f" ℹ 'Kurukṣetra' not found in Chapter 1 first 15 paragraphs")
# 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" Ch {ch['chapter']}: {preview}...")
shown += 1
if shown >= 3:
break
if shown >= 3:
break
# Check chapter headings
print(f"\n--- Chapter Headings ---")
heading_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
if p.startswith("<b>Chapter"):
heading_count += 1
preview = re.sub(r"<[^>]+>", "", p)[:80]
if heading_count <= 5:
print(f" Ch {ch['chapter']}: {preview}")
break
print(f" Total chapter headings: {heading_count}")
# Check closing statements
print(f"\n--- Closing Statements ---")
closing_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p)
if "Bhaktivedānta" in plain and "sings" in plain.lower():
closing_count += 1
break
print(f" Chapters with closing statement: {closing_count} / {len(chapters)}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+904
View File
@@ -0,0 +1,904 @@
#!/usr/bin/env python3
"""Extract Life Comes from Life (1979) from other.html.
The book has 3 intro segments (foreword, introduction, about-the-author),
and 16 chapters ("The First Morning Walk" through "The Sixteenth Morning Walk").
Output:
lib/jsons/books/lcl/intro.json — Book metadata
lib/jsons/books/lcl/intro/*.json — Intro segments
lib/jsons/books/lcl/chapters/*.json — Chapters (01–16)
Usage:
python3 scripts/lcl_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 LCL
# ---------------------------------------------------------------------------
# Book title header class (bold, e.g. "LIFE COMES FROM LIFE (1979)")
BOOK_HEADER_CLASS = "p166"
# Subtitle class
SUBTITLE_CLASS = "p167"
# Bookmark classes — used for section boundary detection
BOOKMARK_CLASS = "p13"
BOOKMARK_CLASS_168 = "p168" # used for Foreword / Introduction bookmarks
# Body paragraph classes (primary content)
BODY_CLASSES = frozenset(["p170", "p171", "p6", "p68", "p182"])
# Dialogue line classes — the main content classes (speakers in bold)
DIALOGUE_CLASSES = frozenset(["p114", "p118", "p178", "p181"])
# Sanskrit verse pāda classes (italic, centered)
VERSE_PADA_CLASSES = frozenset(["p5", "p180"])
# Verse translation classes
TRANSLATION_CLASSES = frozenset(["p29", "p94", "p184", "p186"])
# Chapter heading (bold)
CHAPTER_HEADING_CLASS = "p27"
# Section subheading classes (topic markers within chapters)
SECTION_HEADING_CLASSES = frozenset(["p36", "p64"])
# Section subheading bookmark (contains topic title text)
SECTION_BOOKMARK_CLASS = "p49"
# Foreword heading (bold)
FOREWORD_HEADING_CLASS = "p169"
# Title page / founder-ācārya line
TITLE_PAGE_CLASS = "p3"
# Companion description (italic, who accompanies Prabhupāda)
COMPANION_CLASS = "p177"
# Block quote classes
BLOCKQUOTE_CLASSES = frozenset(["p173", "p175"])
# Attribution classes
ATTRIBUTION_CLASSES = frozenset(["p174", "p176", "p42"])
# Reference / citation line
REFERENCE_CLASS = "p187"
# Classes to skip entirely (empty/decorative/break)
SKIP_CLASSES = frozenset(["p15", "p172", "p179", "p183", "p135"])
# ---------------------------------------------------------------------------
# Ordinal mapping for chapter names
# ---------------------------------------------------------------------------
ORDINAL_MAP = {
"First": 1,
"Second": 2,
"Third": 3,
"Fourth": 4,
"Fifth": 5,
"Sixth": 6,
"Seventh": 7,
"Eighth": 8,
"Ninth": 9,
"Tenth": 10,
"Eleventh": 11,
"Twelfth": 12,
"Thirteenth": 13,
"Fourteenth": 14,
"Fifteenth": 15,
"Sixteenth": 16,
}
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_lcl_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the LCL content.
Start: <p class="p166"> containing "LIFE COMES FROM LIFE"
End: <p class="p13"> containing "LCL: The Challenge"
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = decode_text(p)
# Find start
if start_idx is None:
if cls == BOOK_HEADER_CLASS and "LIFE COMES FROM LIFE" in text.upper():
start_idx = i
continue
# Find end: the "LCL: The Challenge" bookmark
if cls == BOOKMARK_CLASS:
if "LCL:" in text and "The Challenge" in text:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'LIFE COMES FROM LIFE' 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 LCL bookmark tags in the LCL range.
Bookmarks can be either p13 or p168 class with text containing "LCL:".
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 not in (BOOKMARK_CLASS, BOOKMARK_CLASS_168):
continue
# Get text, stripping span noise
text = decode_text(p).strip()
if text.startswith("LCL:"):
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str):
"""Parse a chapter bookmark like 'LCL: The First Morning Walk'.
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"LCL:\s+The\s+(\w+)\s+Morning\s+Walk", text.strip())
if m:
ordinal = m.group(1)
num = ORDINAL_MAP.get(ordinal)
if num is not None:
# Reconstruct the title from the matched text
title = f"The {ordinal} Morning Walk"
return num, title
return None
def parse_intro_bookmark(text: str):
"""Check if this is a Foreword or Introduction bookmark.
Returns the slug ("foreword" or "introduction") or None.
"""
stripped = text.strip()
if stripped == "LCL: Foreword":
return "foreword"
if stripped == "LCL: Introduction":
return "introduction"
return None
def parse_about_author_bookmark(text: str) -> bool:
"""Check if this is the About the Author bookmark."""
return "His Divine Grace" in text and text.strip().startswith("LCL:")
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list:
"""Extract paragraphs for a section (chapter, intro segment).
Handles all CSS classes within the LCL content.
Groups consecutive p5/p180 verse pādas into centered italic divs.
Detects verse patterns in dialogue/body (starts with <i>, has <br>).
Deduplicates p49 + p36 pairs (only keeps the p36 bold version).
"""
result = []
verse_accumulator = []
# Track the last p49 topic text so we can skip it if p36 duplicates it
last_p49_text = None
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, p168)
if cls in (BOOKMARK_CLASS, BOOKMARK_CLASS_168):
continue
# Skip book title header (p166)
if cls == BOOK_HEADER_CLASS:
continue
# Skip subtitle (p167)
if cls == SUBTITLE_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse pādas (p5, p180) — 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 subheading bookmark (p49) — topic title
# Only include if NOT immediately followed by a p36 with the same text
if cls == SECTION_BOOKMARK_CLASS:
p49_text = text.strip()
# Look ahead to see if the next non-empty paragraph is p36 with same content
has_p36_duplicate = False
for j in range(i + 1, min(i + 4, end_idx)):
next_p = all_paragraphs[j]
next_cls = get_css_class(next_p)
if is_empty(next_p) or next_cls in SKIP_CLASSES:
continue
if next_cls == "p36":
next_text = decode_text(next_p).strip()
if next_text == p49_text:
has_p36_duplicate = True
break
if has_p36_duplicate:
# Skip p49; the p36 version will be included
last_p49_text = p49_text
continue
else:
# No p36 duplicate — include p49 as bold subheading
html = decode_html_content(p)
if html:
# Wrap in bold if not already
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
last_p49_text = None
continue
# Section subheading (p36) — bold topic title
if cls == "p36":
html = decode_html_content(p)
if html:
# Ensure it's bold
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
last_p49_text = None
continue
# Section heading variant (p64) — topic title (not bold in HTML)
if cls == "p64":
# p64 is typically followed by p36 with the same text;
# if so, skip p64 and let p36 handle it
p64_text = text.strip()
has_p36_duplicate = False
for j in range(i + 1, min(i + 4, end_idx)):
next_p = all_paragraphs[j]
next_cls = get_css_class(next_p)
if is_empty(next_p) or next_cls in SKIP_CLASSES:
continue
if next_cls == "p36":
next_text = decode_text(next_p).strip()
if next_text == p64_text:
has_p36_duplicate = True
break
if has_p36_duplicate:
# Skip p64; the p36 version will be included
continue
else:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Dialogue lines (p114, p118, p178, p181) — the main content classes
if cls in DIALOGUE_CLASSES:
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
# Foreword heading (p169) — include
if cls == FOREWORD_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Title page / founder-ācārya line (p3) — include
if cls == TITLE_PAGE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Companion description (p177) — italic description
if cls == COMPANION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Block quotes (p173, p175)
if cls in BLOCKQUOTE_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Attribution text (p174, p176, p42)
if cls in ATTRIBUTION_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse translations (p29, p94, p184, p186)
if cls in TRANSLATION_CLASSES:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Reference / citation line (p187)
if cls == REFERENCE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p170, p171, p6, p68, p182)
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
# 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_lcl(soup: BeautifulSoup) -> dict:
"""Extract the entire Life Comes from Life 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 LCL boundaries
lcl_start, lcl_end = find_lcl_boundaries(all_paragraphs)
print(f" LCL content: paragraphs {lcl_start} to {lcl_end}")
print(f" Total paragraphs in range: {lcl_end - lcl_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, lcl_start, lcl_end)
print(f" Found {len(bookmarks)} LCL bookmarks")
for pos, text in bookmarks:
print(f" Para ~{pos}: {text}")
# Step 3: Categorize bookmarks
foreword_bookmark = None # paragraph index
introduction_bookmark = None
about_author_bookmark = None
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]))
continue
intro_slug = parse_intro_bookmark(text)
if intro_slug == "foreword":
foreword_bookmark = pos
elif intro_slug == "introduction":
introduction_bookmark = pos
elif parse_about_author_bookmark(text):
about_author_bookmark = pos
print(
f" Foreword bookmark: {'found' if foreword_bookmark is not None else 'NOT FOUND'}"
)
print(
f" Introduction bookmark: {'found' if introduction_bookmark is not None else 'NOT FOUND'}"
)
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
print(
f" About the Author bookmark: {'found' if about_author_bookmark is not None else 'NOT FOUND'}"
)
# Build an ordered list of all section start positions for boundary computation
all_section_starts = sorted(
([foreword_bookmark] if foreword_bookmark is not None else [])
+ ([introduction_bookmark] if introduction_bookmark is not None else [])
+ [pos for pos, _, _ in chapter_bookmarks]
+ ([about_author_bookmark] if about_author_bookmark is not None else [])
)
def next_section_after(pos):
"""Return the next section start after `pos`, or lcl_end."""
for s in all_section_starts:
if s > pos:
return s
return lcl_end
# Step 4: Extract Foreword
foreword = None
if foreword_bookmark is not None:
fw_content_start = foreword_bookmark + 1
fw_content_end = next_section_after(foreword_bookmark)
fw_paras = extract_paragraphs(all_paragraphs, fw_content_start, fw_content_end)
foreword = {
"slug": "foreword",
"title": "Foreword",
"type": "intro-segment",
"book": "lcl",
"order": 1,
"paragraphs": fw_paras,
}
# Step 5: Extract Introduction
introduction = None
if introduction_bookmark is not None:
intro_content_start = introduction_bookmark + 1
intro_content_end = next_section_after(introduction_bookmark)
intro_paras = extract_paragraphs(
all_paragraphs, intro_content_start, intro_content_end
)
introduction = {
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "lcl",
"order": 2,
"paragraphs": intro_paras,
}
# Step 6: Extract chapters 1–16
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
content_end = next_section_after(ch_pos)
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "lcl",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 7: Extract About the Author
about_the_author = None
if about_author_bookmark is not None:
ata_content_start = about_author_bookmark + 1
ata_content_end = lcl_end
ata_paras = extract_paragraphs(
all_paragraphs, ata_content_start, ata_content_end
)
about_the_author = {
"slug": "about-the-author",
"title": "About the Author",
"type": "intro-segment",
"book": "lcl",
"order": 99,
"paragraphs": ata_paras,
}
# Step 8: Build intro segments list
intro_segments = []
if foreword:
intro_segments.append(foreword)
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,
"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", "lcl")
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 Life Comes from Life (1979)...\n")
result = extract_lcl(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "lcl",
"title": "Life Comes from Life",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1979,
"description": "Morning walks with Śrīla Prabhupāda — a critique of modern science's theories on the origin of life.",
"totalChapters": 16,
"chapters": chapter_listing,
}
intro_path = os.path.join(output_base, "intro.json")
write_json(intro_meta, intro_path)
print(f"\n Written: {intro_path}")
# --- Write intro segments ---
print(f"\n--- Intro Segments ---")
for seg in intro_segments:
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
write_json(seg, filepath)
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
)
# --- Write chapters ---
print(f"\n--- Chapters ---")
for ch in chapters:
ch_num_str = str(ch["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
write_json(ch, filepath)
print(
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
f"{ch['title']}"
)
# --- Summary ---
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
total_paras = total_intro_paras + total_chapter_paras
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Intro segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Chapters: {len(chapters)}")
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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 8:
print(f"\n--- Sample: Chapter 8 ---")
ch8 = [c for c in chapters if c["chapter"] == 8]
if ch8:
for i, p in enumerate(ch8[0]["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 16:
print(f"\n--- Sample: Chapter 16 (last) ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 16 chapters present
expected_chapters = set(range(1, 17))
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 16 chapters present")
# Check intro segments
expected_intros = {"foreword", "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 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
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"][:15])
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'")
else:
print(f" ℹ 'Prabhupāda' not found in Chapter 1 first 15 paragraphs")
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 15 paragraphs (may be normal)"
)
# Check for Vedas
if "Vedas" in ch1_text or "Vedic" in ch1_text:
print(f" ✅ 'Vedas'/'Vedic' found in Chapter 1")
# Dialogue speaker check
print(f"\n--- Dialogue Speaker Check ---")
speaker_counts = {}
for ch in chapters:
for p in ch["paragraphs"]:
# Look for bold speaker patterns: <b>SPEAKER.</b> or <b>SPEAKER:</b>
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" Ch {ch['chapter']}: {preview}...")
shown += 1
if shown >= 3:
break
if shown >= 3:
break
# Check section subheadings (topic titles)
print(f"\n--- Section Subheadings in Chapters ---")
subheading_count = 0
sample_subheadings = []
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p).strip()
# A bold-only paragraph that's short is likely a subheading
if p.startswith("<b>") and p.endswith("</b>") and len(plain) < 80:
subheading_count += 1
if len(sample_subheadings) < 5:
sample_subheadings.append(f" Ch {ch['chapter']}: {plain}")
print(f" Total subheadings: {subheading_count}")
for s in sample_subheadings:
print(s)
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+704
View File
@@ -0,0 +1,704 @@
#!/usr/bin/env python3
"""Extract Light of the Bhāgavata (1983) from other.html.
The book has 4 intro segments (front-pages, preface, acknowledgement, the-artist)
and 48 numbered texts, each with a verse translation and commentary paragraphs.
Output:
lib/jsons/books/lob/intro.json — Book metadata
lib/jsons/books/lob/intro/*.json — Intro segments
lib/jsons/books/lob/chapters/*.json — 48 texts
Usage:
python3 scripts/lob_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 LoB
# ---------------------------------------------------------------------------
# Book title header class
BOOK_HEADER_CLASS = "p12"
# Bookmark class — used for section boundary detection
BOOKMARK_CLASS = "p49"
# Body paragraph classes (primary content)
BODY_CLASS = "p198"
# First commentary paragraph (slightly different styling, same role as p198)
FIRST_COMMENTARY_CLASS = "p201"
# Verse translation inside <table> elements
VERSE_TRANSLATION_CLASS = "p200"
# Text number heading (bold, e.g. <b>1</b>)
TEXT_NUMBER_CLASS = "p199"
# Section heading (bold, e.g. "Preface", "Acknowledgement")
SECTION_HEADING_CLASS = "p27"
# Sanskrit verse pādas (italic)
VERSE_PADA_CLASS = "p5"
# Front pages text
FRONT_PAGES_CLASS = "p196"
# Verse reference text (e.g. [Bg. 15.17])
VERSE_REF_CLASS = "p203"
# Classes to skip entirely (empty/decorative/breaks)
SKIP_CLASSES = frozenset(["p195", "p197", "p15", "p202"])
# ---------------------------------------------------------------------------
# Bookmark parsers
# ---------------------------------------------------------------------------
def parse_text_bookmark(text: str) -> int | None:
"""Parse a text bookmark like 'LoB:1' or 'LoB:48'.
Returns text number (1-48) or None.
"""
text = text.strip()
m = re.match(r"LoB:(\d+)", text)
if m:
return int(m.group(1))
return None
def parse_intro_bookmark(text: str) -> str | None:
"""Parse intro section bookmarks. Returns slug or None."""
text = text.strip()
if text == "LoB: Preface":
return "preface"
if text == "LoB: Acknowledgement":
return "acknowledgement"
if text == "LoB: The Artist":
return "the-artist"
return None
INTRO_SLUG_TO_TITLE = {
"preface": "Preface",
"acknowledgement": "Acknowledgement",
"the-artist": "The Artist",
}
INTRO_SLUG_TO_ORDER = {
"front-pages": 1,
"preface": 2,
"acknowledgement": 3,
"the-artist": 4,
}
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_lob_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the LoB content.
Start: <p class="p12"> containing "Light of the Bhägawata"
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 = decode_text(p)
if "Light of the Bh" in text and "gawata" in text:
start_idx = i
elif start_idx is not None:
# Next p12 after LoB start = end of LoB
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Light of the Bhāgawata' 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 p49 bookmark tags in the LoB 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).strip()
if text.startswith("LoB"):
bookmarks.append((i, text))
return bookmarks
# ---------------------------------------------------------------------------
# Paragraph extraction
# ---------------------------------------------------------------------------
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
is_front_pages: bool = False,
is_numbered_text: bool = False,
) -> list:
"""Extract paragraphs for a section.
Handles all CSS classes within the LoB content.
Groups consecutive p5 verse pādas into centered italic divs.
Handles p200 verse translation paragraphs (from inside tables).
"""
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 (p49)
if cls == BOOKMARK_CLASS:
continue
# Skip book title header (p12)
if cls == BOOK_HEADER_CLASS:
continue
# Skip text number headings (p199) — we already know the text number
if cls == TEXT_NUMBER_CLASS:
continue
# Sanskrit verse pādas (p5) — accumulate into centered block
if cls == VERSE_PADA_CLASS:
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()
# Verse translation (p200) — bold translation from table
if cls == VERSE_TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
# Clean up trailing empty <b></b> artifacts
html = re.sub(r"<b>\s*</b>", "", html).strip()
# Normalize nested/split bold tags: strip all <b>/<\/b> and
# re-wrap the whole text in a single <b>…</b>.
# This fixes source artifacts like "Attracted b<b>y...up</b>on"
inner = re.sub(r"</?b>", "", html).strip()
html = f"<b>{inner}</b>"
result.append(html)
continue
# First commentary paragraph (p201) — treat same as body
if cls == FIRST_COMMENTARY_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body/commentary paragraphs (p198)
if cls == BODY_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Section heading (p27, bold)
if cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Front pages text (p196)
if cls == FRONT_PAGES_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Verse reference text (p203) — include as-is
if cls == VERSE_REF_CLASS:
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)
# Flush any trailing verse
flush_verse()
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_lob(soup: BeautifulSoup) -> dict:
"""Extract the entire Light of the Bhāgavata from the parsed HTML.
Returns a dict with:
"intro_segments": list of intro segment dicts
"texts": list of numbered text dicts (1-48)
"chapter_listing": list of {number, title} for intro.json
"""
all_paragraphs = list(soup.find_all("p"))
# Step 1: Find LoB boundaries
lob_start, lob_end = find_lob_boundaries(all_paragraphs)
print(f" LoB content: paragraphs {lob_start} to {lob_end}")
print(f" Total paragraphs in range: {lob_end - lob_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, lob_start, lob_end)
print(f" Found {len(bookmarks)} LoB bookmarks")
for pos, text in bookmarks:
print(f" Line ~{pos}: {text}")
# Step 3: Categorize bookmarks
intro_bookmarks = [] # (para_idx, slug)
text_bookmarks = [] # (para_idx, text_num)
for pos, text in bookmarks:
text_num = parse_text_bookmark(text)
if text_num is not None:
text_bookmarks.append((pos, text_num))
else:
slug = parse_intro_bookmark(text)
if slug is not None:
intro_bookmarks.append((pos, slug))
print(f" Intro bookmarks: {len(intro_bookmarks)}")
print(f" Text bookmarks: {len(text_bookmarks)}")
# Step 4: Extract front-pages (from p12 header to first intro bookmark)
front_pages_start = lob_start + 1 # after p12 header
if intro_bookmarks:
front_pages_end = intro_bookmarks[0][0]
elif text_bookmarks:
front_pages_end = text_bookmarks[0][0]
else:
front_pages_end = lob_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": "lob",
"order": 1,
"paragraphs": front_pages_paras,
}
# Step 5: Extract intro segments (preface, acknowledgement, the-artist)
intro_segments = [front_pages]
# Build ordered list of all section starts for boundary computation
all_section_starts = sorted(
[pos for pos, _ in intro_bookmarks] + [pos for pos, _ in text_bookmarks]
)
for idx, (bm_pos, slug) in enumerate(intro_bookmarks):
content_start = bm_pos + 1 # after the bookmark paragraph
# Content ends at the next bookmark/section start
next_starts = [s for s in all_section_starts if s > bm_pos]
content_end = next_starts[0] if next_starts else lob_end
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
segment = {
"slug": slug,
"title": INTRO_SLUG_TO_TITLE.get(slug, slug.replace("-", " ").title()),
"type": "intro-segment",
"book": "lob",
"order": INTRO_SLUG_TO_ORDER.get(slug, 10 + idx),
"paragraphs": paras,
}
intro_segments.append(segment)
# Step 6: Extract texts 1-48
texts = []
for idx, (txt_pos, txt_num) in enumerate(text_bookmarks):
content_start = txt_pos + 1 # after the bookmark paragraph
# Content ends at the next text bookmark or lob_end
if idx + 1 < len(text_bookmarks):
content_end = text_bookmarks[idx + 1][0]
else:
content_end = lob_end
paragraphs = extract_paragraphs(
all_paragraphs, content_start, content_end, is_numbered_text=True
)
txt = {
"book": "lob",
"chapter": txt_num,
"title": f"Text {txt_num}",
"type": "chapter",
"paragraphs": paragraphs,
}
texts.append(txt)
# Step 7: Build chapter listing for intro.json
chapter_listing = [{"number": t["chapter"], "title": t["title"]} for t in texts]
return {
"intro_segments": intro_segments,
"texts": texts,
"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", "lob")
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 Light of the Bhāgavata (1983)...\n")
result = extract_lob(soup)
intro_segments = result["intro_segments"]
texts = result["texts"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "lob",
"title": "Light of the Bhāgavata",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1983,
"description": "A unique blend of Vedic wisdom and art — 48 poetic descriptions of the autumn season with spiritual commentaries.",
"totalChapters": 48,
"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 texts (chapters) ---
print(f"\n--- Texts ---")
for txt in texts:
num_str = str(txt["chapter"]).zfill(2)
filepath = os.path.join(output_base, "chapters", f"{num_str}.json")
write_json(txt, filepath)
print(
f" Text {txt['chapter']:>2}: {len(txt['paragraphs']):>3} paragraphs — "
f"{txt['title']}"
)
# --- Summary ---
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
total_text_paras = sum(len(t["paragraphs"]) for t in texts)
total_paras = total_intro_paras + total_text_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" Texts: {len(texts)}")
print(f" Total paragraphs: {total_paras}")
print(f" Intro: {total_intro_paras}")
print(f" Texts: {total_text_paras}")
if texts:
print(f" Avg paras/text: {total_text_paras / len(texts):.1f}")
print(f" Output directory: {output_base}/")
# --- Samples ---
print(f"\n--- Sample: Text 1 ---")
if texts:
t1 = texts[0]
for i, p in enumerate(t1["paragraphs"][:4]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(texts) >= 24:
print(f"\n--- Sample: Text 24 ---")
t24 = texts[23]
for i, p in enumerate(t24["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(texts) >= 48:
print(f"\n--- Sample: Text 48 (last) ---")
t48 = texts[47]
for i, p in enumerate(t48["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 48 texts present
expected_texts = set(range(1, 49))
actual_texts = set(t["chapter"] for t in texts)
missing = expected_texts - actual_texts
extra = actual_texts - expected_texts
if missing:
print(f" ⚠ Missing texts: {sorted(missing)}")
if extra:
print(f" ⚠ Extra texts: {sorted(extra)}")
if not missing and not extra:
print(f" ✅ All 48 texts present")
# Check all 4 intro segments present
expected_intros = {"front-pages", "preface", "acknowledgement", "the-artist"}
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 4 intro segments present")
# Check each text has at least 2 paragraphs (1 translation + 1 commentary)
thin_texts = [t["chapter"] for t in texts if len(t["paragraphs"]) < 2]
if thin_texts:
print(f" ⚠ Texts with fewer than 2 paragraphs: {thin_texts}")
else:
print(f" ✅ All texts have at least 2 paragraphs (translation + commentary)")
# Check empty texts
empty_texts = [t["chapter"] for t in texts if len(t["paragraphs"]) == 0]
if empty_texts:
print(f" ⚠ Empty texts (0 paragraphs): {empty_texts}")
else:
print(f" ✅ No empty texts")
# Check that each numbered text starts with a bold translation
texts_without_translation = []
for t in texts:
if t["paragraphs"]:
first = t["paragraphs"][0]
if not first.strip().startswith("<b>"):
texts_without_translation.append(t["chapter"])
if texts_without_translation:
print(
f" ⚠ Texts whose first paragraph is not a bold translation: "
f"{texts_without_translation}"
)
else:
print(f" ✅ All texts start with bold verse translation")
# Check ScaGoudy decoding — look for common encoded characters
encoded_chars = set("äåöëòçüéàùè")
suspect_count = 0
all_sections = texts + intro_segments
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 (embedded Sanskrit verses with p5)
verse_block_count = 0
for t in texts:
for p in t["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
verse_block_count += 1
print(f" ℹ {verse_block_count} verse blocks in texts (embedded Sanskrit verses)")
# ScaGoudy decoding spot check
print(f"\n--- ScaGoudy Decoding Spot Check ---")
if texts:
t1_text = " ".join(texts[0]["paragraphs"][:10])
if "Kṛṣṇa" in t1_text or "kṛṣṇa" in t1_text:
print(f" ✅ 'Kṛṣṇa' correctly decoded in Text 1")
elif "Kåñëa" in t1_text or "kåñëa" in t1_text:
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
else:
print(
f" ℹ 'Kṛṣṇa' not found in Text 1 first 10 paragraphs (may be normal)"
)
# Check for "Prabhupāda" in intro segments
all_intro_text = " ".join(
p for seg in intro_segments for p in seg["paragraphs"][:10]
)
if "Prabhupāda" in all_intro_text:
print(f" ✅ 'Prabhupāda' correctly decoded in intro")
elif "Prabhupäda" in all_intro_text:
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
# Check for "Śrīmad" or "Bhāgavatam"
if "Bhāgavata" in all_intro_text or "Bhāgawata" in all_intro_text:
print(f" ✅ 'Bhāgavata' correctly decoded in intro")
# Sample verse blocks
if verse_block_count > 0:
print(f"\n--- Sample Verse Blocks ---")
shown = 0
for t in texts:
for p in t["paragraphs"]:
if '<div style="text-align:center"><i>' in p:
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" Text {t['chapter']}: {preview}...")
shown += 1
if shown >= 3:
break
if shown >= 3:
break
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+806
View File
@@ -0,0 +1,806 @@
#!/usr/bin/env python3
"""Extract Mukunda-mālā-stotra 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 = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
# --- CSS class constants ---
BOOK_HEADER_CLASS = "p12" # Book title
AUTHOR_SUBTITLE_CLASS = "p28" # Author subtitle
INTRO_BOOKMARK_CLASS = "p64" # "MM Introduction" bookmark
SECTION_HEADING_CLASS = "p31" # "Introduction" heading (bold)
SUB_HEADING_CLASS = "p109" # "About the Present Edition" sub-heading
BODY_CLASS = "p48" # Body/purport paragraphs (MAIN content class)
ATTRIBUTION_CLASS = "p47" # "—The Publishers"
VERSE_BOOKMARK_CLASS = "p49" # Verse bookmarks ("MM mantra N")
VERSE_HEADING_V1_CLASS = "p6" # "TEXT 1" heading (variant for text 1)
VERSE_HEADING_CLASS = "p57" # "TEXT N" heading for texts 2-6 (bold)
TEXT_LABEL_CLASS = "p52" # "TEXT" label (skip)
PADA_TRANSLIT_CLASS = "p5" # Transliteration pādas (italic)
SYNONYMS_HEADER_CLASS = "p165" # "SYNONYMS" header
SYNONYM_TEXT_CLASS = "p54" # Synonyms text
TRANSLATION_LABEL_CLASS = "p55" # "TRANSLATION" / "PURPORT" headers
TRANSLATION_TEXT_CLASS = "p56" # Translation text (bold)
QUOTED_VERSE_CLASS = "p53" # Embedded Sanskrit verse in purport
COMMENTATOR_CLASS = "p44" # Verse translation within purport
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:
"""Combine pādas into 2-line verse format.
If the number of pādas is odd, the first one is treated as a
standalone line (e.g. speaker attribution like 'uvāca').
Empty strings in the list act as stanza-break sentinels and are
rendered as blank lines in the output.
"""
if not padas:
return ""
# Split pādas into stanza groups separated by "" sentinels
groups: list[list[str]] = [[]]
for p in padas:
if p == "":
groups.append([])
else:
groups[-1].append(p)
result_lines = []
for group in groups:
if not group:
continue
lines = []
i = 0
# If odd number of pādas, first one is standalone (speaker attribution)
if len(group) % 2 == 1:
lines.append(group[0])
i = 1
# Pair remaining pādas
while i < len(group) - 1:
lines.append(group[i] + " " + group[i + 1])
i += 2
# Handle any trailing single pāda
if i < len(group):
lines.append(group[i])
result_lines.append("\n".join(lines))
return "\n\n".join(result_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 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}")
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_mm_boundaries(all_paragraphs: list) -> tuple[int, int]:
"""Find the start and end paragraph indices of the Mukunda-mālā-stotra content.
Start: <p class="p12"> containing "Mukunda-mälä-stotra" (ScaGoudy encoded)
End: next <p class="p12"> after start (start of next book — Nārada-bhakti-sūtra)
"""
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:
text = p.get_text().strip()
if start_idx is None and "Mukunda" in text:
start_idx = i
continue
# Next p12 after our start → end boundary
if start_idx is not None:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Mukunda-mālā-stotra' header in other.html!")
sys.exit(1)
return start_idx, end_idx
def parse_verse_bookmark(text: str) -> int | None:
"""Parse a verse bookmark like 'MM mantra N'.
Returns mantra number (1-6), or None.
"""
text = text.strip()
m = re.match(r"MM\s+mantra\s+(\d+)", text)
if m:
return int(m.group(1))
return None
# ---------------------------------------------------------------------------
# Section boundary detection within MM range
# ---------------------------------------------------------------------------
def find_section_boundaries(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
"""Find key section boundaries within the MM content.
Returns dict with section names and their start indices.
"""
sections = {}
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
text = p.get_text().strip()
# Introduction bookmark (p64 with "MM Introduction")
if cls == INTRO_BOOKMARK_CLASS and "MM Introduction" in text:
sections["introduction_bookmark"] = i
# First verse bookmark (MM mantra 1)
elif cls == VERSE_BOOKMARK_CLASS:
decoded = decode_text(p)
vnum = parse_verse_bookmark(decoded)
if vnum == 1 and "first_verse" not in sections:
sections["first_verse"] = i
break
return sections
# ---------------------------------------------------------------------------
# Intro segment extraction
# ---------------------------------------------------------------------------
def extract_intro_segment(
all_paragraphs: list, start_idx: int, end_idx: int
) -> list[str]:
"""Extract paragraphs for the introduction segment.
Decodes ScaGoudy, preserves HTML formatting, skips empty paragraphs.
Includes p48 (body), p47 (attribution), p109 (sub-heading) content.
"""
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 (
INTRO_BOOKMARK_CLASS,
BOOK_HEADER_CLASS,
SECTION_HEADING_CLASS,
AUTHOR_SUBTITLE_CLASS,
):
continue
# Include body, attribution, and sub-heading classes
if cls in (BODY_CLASS, ATTRIBUTION_CLASS, SUB_HEADING_CLASS):
html = decode_html_content(p)
if html:
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 MM content using a state machine.
States: IDLE → VERSE_START → TRANSLITERATION → SYNONYMS →
TRANSLATION → POST_TRANSLATION → PURPORT → (back to IDLE)
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)
verse_num = parse_verse_bookmark(text)
if verse_num is not None:
reference = f"MM {verse_num}"
current_verse = {
"book_title": "Mukunda-mālā-stotra",
"chapter": 1,
"verse": verse_num,
"reference": reference,
"devanagari": "",
"transliteration": "",
"_padas": [],
"synonyms": [],
"translation": "",
"purport": [],
}
state = "VERSE_START"
continue
if not current_verse:
continue
# --- Verse heading (p6 for text 1, p57 for texts 2-6) - confirms text number ---
if cls in (VERSE_HEADING_V1_CLASS, VERSE_HEADING_CLASS):
inner_text = p.get_text().strip().upper()
# If this says "TEXT N" it's a verse heading — skip it
if "TEXT" in inner_text and state == "VERSE_START":
continue
# p57 can also be "PURPORT" header
if "PURPORT" in inner_text:
state = "PURPORT"
continue
# p57 can also be "TRANSLATION" header
if "TRANSLATION" in inner_text:
state = "TRANSLATION"
continue
# Otherwise skip if we're in VERSE_START
if state == "VERSE_START":
continue
# --- TEXT label (p52) - transition to transliteration ---
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(".")
line = line.strip()
if line:
current_verse["_padas"].append(line)
continue
# --- SYNONYMS header (p165) ---
if cls == SYNONYMS_HEADER_CLASS:
state = "SYNONYMS"
continue
# --- Synonyms text (p54) ---
if cls == SYNONYM_TEXT_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 label (p55) — check text content to distinguish from PURPORT ---
if cls == TRANSLATION_LABEL_CLASS and current_verse:
inner_text = p.get_text().strip().upper()
if "TRANSLATION" in inner_text:
state = "TRANSLATION"
elif "PURPORT" in inner_text:
state = "PURPORT"
continue
# --- PURPORT label via p57 (alt class) ---
if cls == VERSE_HEADING_CLASS and current_verse:
inner_text = p.get_text().strip().upper()
if "PURPORT" in inner_text:
state = "PURPORT"
continue
elif "TRANSLATION" in inner_text:
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)
html = html.strip()
current_verse["translation"] = html
state = "POST_TRANSLATION"
continue
# --- Purport paragraphs (p48) ---
if (
cls == BODY_CLASS
and current_verse
and state in ("PURPORT", "POST_TRANSLATION")
):
state = "PURPORT"
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Embedded Sanskrit verse in purport (p53) ---
if cls == QUOTED_VERSE_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
# Wrap as centered italic div for display
current_verse["purport"].append(
f'<div class="quoted-verse">{html}</div>'
)
continue
# --- Verse translation within purport (p44) ---
if cls == COMMENTATOR_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- p6 paragraphs in purport context (can appear as body text) ---
if (
cls == VERSE_HEADING_V1_CLASS
and current_verse
and state in ("PURPORT", "POST_TRANSLATION")
):
state = "PURPORT"
html = decode_html_content(p)
if html:
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, blank sentinels become blank lines
trans_lines = []
for p in padas:
if p == "":
trans_lines.append("") # blank line between stanza groups
else:
trans_lines.append(p)
verse["transliteration"] = "\n".join(trans_lines)
# 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"]
# ---------------------------------------------------------------------------
# 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", "mm")
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 MM boundaries ---
mm_start, mm_end = find_mm_boundaries(all_paragraphs)
print(f"MM content: paragraphs {mm_start} to {mm_end}")
# --- Find section boundaries ---
sections = find_section_boundaries(all_paragraphs, mm_start, mm_end)
print(f"\nSection boundaries found:")
for name, idx in sorted(sections.items(), key=lambda x: x[1]):
print(f" [{idx}] {name}")
intro_bm = sections.get("introduction_bookmark")
first_verse = sections.get("first_verse")
if first_verse is None:
print("ERROR: Could not find first verse bookmark (MM mantra 1)!")
sys.exit(1)
# --- Extract intro segment ---
print("\n--- Extracting Intro Segment ---")
intro_segments = []
if intro_bm is not None:
intro_end = first_verse
intro_paras = extract_intro_segment(all_paragraphs, intro_bm, intro_end)
intro_segment = {
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "mm",
"order": 1,
"paragraphs": intro_paras,
}
intro_segments.append(intro_segment)
print(f" Introduction: {len(intro_paras)} paragraphs")
# --- Extract verses ---
print("\n--- Extracting Verses ---")
verses = extract_verses(all_paragraphs, first_verse, mm_end)
print(f"Extracted {len(verses)} verses (expected 6: Texts 1-6)")
# --- Write intro.json ---
intro_meta = {
"book": "mm",
"title": "Mukunda-mālā-stotra",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1989,
"description": "King Kulaśekhara's prayers to Lord Mukunda — translations and purports for verses 1–6.",
"totalChapters": 1,
"chapters": [
{
"number": 1,
"title": "Prayers of King Kulaśekhara",
"totalVerses": 6,
}
],
}
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["verse"]
filename = f"{str(text_num).zfill(2)}.json"
filepath = os.path.join(output_base, "chapters", "01", "verses", filename)
write_json(verse, filepath)
# --- Summary ---
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Book: Mukunda-mālā-stotra")
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)}")
texts_found = sorted([v["verse"] 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 (may be intentional)")
if issues:
print(" ISSUES FOUND:")
for issue in issues:
print(issue)
else:
print(
" All verses have transliteration, devanagari, synonyms, and translation ✓"
)
# Check expected verse count
expected_texts = set(range(1, 7))
found_texts = set(v["verse"] for v in verses)
missing = expected_texts - found_texts
extra = found_texts - expected_texts
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 6 texts found ✓")
# --- ScaGoudy spot checks ---
print(f"\n--- ScaGoudy Spot Checks ---")
for verse in verses:
ref = verse["reference"]
trans = verse["transliteration"]
# Check for leftover ScaGoudy characters
leftover = set()
for ch in trans:
if ch in SCAGOUDY_TO_IAST:
leftover.add(ch)
if leftover:
print(
f" WARNING: {ref} transliteration has leftover ScaGoudy chars: {leftover}"
)
else:
print(f" {ref}: transliteration clean ✓")
# --- Samples ---
print(f"\n--- Sample: Text 1 (first) ---")
t1 = [v for v in verses if v["verse"] == 1]
if t1:
v = t1[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:100]}...")
print(f" Devanagari: {v['devanagari'][:100]}...")
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"])[:120]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Text 3 (middle) ---")
t3 = [v for v in verses if v["verse"] == 3]
if t3:
v = t3[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:100]}...")
print(f" Devanagari: {v['devanagari'][:100]}...")
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"])[:120]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Text 6 (last) ---")
t6 = [v for v in verses if v["verse"] == 6]
if t6:
v = t6[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:100]}...")
print(f" Devanagari: {v['devanagari'][:100]}...")
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"])[:120]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
# --- Intro segment details ---
print(f"\n--- Intro Segment Details ---")
for seg in intro_segments:
print(f" {seg['title']}: {len(seg['paragraphs'])} paragraphs")
if seg["paragraphs"]:
first_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][0])[:120]
print(f" First ¶: {first_preview}...")
last_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][-1])[:120]
print(f" Last ¶: {last_preview}...")
if __name__ == "__main__":
main()
+808
View File
@@ -0,0 +1,808 @@
#!/usr/bin/env python3
"""Extract Nārada-bhakti-sūtra 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 = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"ï": "ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"û": "ṁl",
"ÿ": "ḷ",
}
# --- CSS class constants ---
BOOK_HEADER_CLASS = "p12" # Book title
SECTION_HEADING_CLASS = (
"p13" # Section bookmarks ("NBS Introduction", "NBS: The Value of Devotion")
)
CHAPTER_NUM_CLASS = "p31" # "Chapter 1" heading
CHAPTER_TITLE_CLASS = "p27" # "The Value of Devotion" heading
BODY_CLASS = "p48" # Body/purport paragraphs (MAIN content class)
VERSE_BOOKMARK_CLASS = "p49" # Verse bookmarks ("NBS 1" through "NBS 8")
VERSE_HEADING_CLASS = "p50" # "SŪTRA N" heading (bold)
DEVANAGARI_SKIP_CLASS_1 = "p220" # Garbled Devanagari in custom font — SKIP
DEVANAGARI_SKIP_CLASS_2 = "p51" # Also garbled Devanagari — SKIP
TEXT_LABEL_CLASS = "p52" # "SŪTRA" label
PADA_TRANSLIT_CLASS = "p5" # Transliteration pādas (italic)
SYNONYMS_HEADER_CLASS = "p165" # "SYNONYMS" header
SYNONYM_TEXT_CLASS = "p221" # Synonyms text (NOTE: p221 for NBS, not p54!)
SYNONYM_TEXT_CLASS_ALT = "p54" # Alternate synonyms class (just in case)
TRANSLATION_LABEL_CLASS = "p55" # "TRANSLATION" / "PURPORT" headers
TRANSLATION_TEXT_CLASS = "p56" # Translation text (bold)
PURPORT_LABEL_ALT_CLASS = "p57" # Alternate "PURPORT" header
QUOTED_VERSE_CLASS = "p53" # Embedded Sanskrit verse in purport
COMMENTATOR_CLASS = "p44" # Verse translation within purport
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:
"""Combine pādas into 2-line verse format.
If the number of pādas is odd, the first one is treated as a
standalone line (e.g. speaker attribution like 'uvāca').
Empty strings in the list act as stanza-break sentinels and are
rendered as blank lines in the output.
"""
if not padas:
return ""
# Split pādas into stanza groups separated by "" sentinels
groups: list[list[str]] = [[]]
for p in padas:
if p == "":
groups.append([])
else:
groups[-1].append(p)
result_lines = []
for group in groups:
if not group:
continue
lines = []
i = 0
# If odd number of pādas, first one is standalone (speaker attribution)
if len(group) % 2 == 1:
lines.append(group[0])
i = 1
# Pair remaining pādas
while i < len(group) - 1:
lines.append(group[i] + " " + group[i + 1])
i += 2
# Handle any trailing single pāda
if i < len(group):
lines.append(group[i])
result_lines.append("\n".join(lines))
return "\n\n".join(result_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 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}")
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
def find_nbs_boundaries(all_paragraphs: list) -> tuple[int, int]:
"""Find the start and end paragraph indices of the Nārada-bhakti-sūtra content.
Start: <p class="p12"> containing "Närada-bhakti-sütra" (ScaGoudy encoded)
End: next <p class="p12"> after start (start of next book)
"""
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:
text = p.get_text().strip()
if start_idx is None and (
"rada-bhakti" in text or "Närada" in text or "Narada" in text
):
start_idx = i
continue
# Next p12 after our start → end boundary
if start_idx is not None:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Nārada-bhakti-sūtra' header in other.html!")
sys.exit(1)
return start_idx, end_idx
def parse_verse_bookmark(text: str) -> int | None:
"""Parse a verse bookmark like 'NBS N'.
Returns sūtra number (1-8), or None.
"""
text = text.strip()
m = re.match(r"NBS\s+(\d+)", text)
if m:
return int(m.group(1))
return None
# ---------------------------------------------------------------------------
# Section boundary detection within NBS range
# ---------------------------------------------------------------------------
def find_section_boundaries(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
"""Find key section boundaries within the NBS content.
Returns dict with section names and their start indices.
"""
sections = {}
for i in range(start_idx, end_idx):
p = all_paragraphs[i]
cls = get_css_class(p)
text = p.get_text().strip()
# Introduction bookmark (p13 with "NBS Introduction")
if cls == SECTION_HEADING_CLASS and "NBS Introduction" in text:
sections["introduction_bookmark"] = i
# Chapter bookmark (p13 with "NBS: The Value of Devotion") — note but skip
elif cls == SECTION_HEADING_CLASS and "NBS:" in text and "Value" in text:
sections["chapter_bookmark"] = i
# First verse bookmark (NBS 1)
elif cls == VERSE_BOOKMARK_CLASS:
decoded = decode_text(p)
vnum = parse_verse_bookmark(decoded)
if vnum == 1 and "first_verse" not in sections:
sections["first_verse"] = i
break
return sections
# ---------------------------------------------------------------------------
# Intro segment extraction
# ---------------------------------------------------------------------------
def extract_intro_segment(
all_paragraphs: list, start_idx: int, end_idx: int
) -> list[str]:
"""Extract paragraphs for the introduction segment.
Decodes ScaGoudy, preserves HTML formatting, skips empty paragraphs.
Includes p48 (body) content and other content classes.
"""
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_HEADING_CLASS,
BOOK_HEADER_CLASS,
CHAPTER_NUM_CLASS,
CHAPTER_TITLE_CLASS,
):
continue
# Include body paragraphs
if cls in (BODY_CLASS,):
html = decode_html_content(p)
if html:
result.append(html)
# Include quoted verses in introduction
elif cls == QUOTED_VERSE_CLASS:
html = decode_html_content(p)
if html:
result.append(f'<div class="quoted-verse">{html}</div>')
# Include verse translations in introduction
elif cls == COMMENTATOR_CLASS:
html = decode_html_content(p)
if html:
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 NBS content using a state machine.
States: IDLE → VERSE_START → TRANSLITERATION → SYNONYMS →
TRANSLATION → POST_TRANSLATION → PURPORT → (back to IDLE)
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)
verse_num = parse_verse_bookmark(text)
if verse_num is not None:
reference = f"NBS {verse_num}"
current_verse = {
"book_title": "Nārada-bhakti-sūtra",
"chapter": 1,
"verse": verse_num,
"reference": reference,
"devanagari": "",
"transliteration": "",
"_padas": [],
"synonyms": [],
"translation": "",
"purport": [],
}
state = "VERSE_START"
continue
if not current_verse:
continue
# --- Skip garbled Devanagari lines (p220, p51) ---
if cls in (DEVANAGARI_SKIP_CLASS_1, DEVANAGARI_SKIP_CLASS_2):
continue
# --- Verse heading (p50 — "SŪTRA N") - confirms sūtra number ---
if cls == VERSE_HEADING_CLASS:
inner_text = p.get_text().strip().upper()
# This is the "SŪTRA N" heading — skip it
if state == "VERSE_START":
continue
# --- TEXT/SŪTRA label (p52) - transition to transliteration ---
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(".")
line = line.strip()
if line:
current_verse["_padas"].append(line)
continue
# --- SYNONYMS header (p165) ---
if cls == SYNONYMS_HEADER_CLASS:
state = "SYNONYMS"
continue
# --- Synonyms text (p221 for NBS, also check p54 as fallback) ---
if cls in (SYNONYM_TEXT_CLASS, SYNONYM_TEXT_CLASS_ALT) 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 label (p55) — check text content to distinguish from PURPORT ---
if cls == TRANSLATION_LABEL_CLASS and current_verse:
inner_text = p.get_text().strip().upper()
if "TRANSLATION" in inner_text:
state = "TRANSLATION"
elif "PURPORT" in inner_text:
state = "PURPORT"
continue
# --- PURPORT label via p57 (alt class) ---
if cls == PURPORT_LABEL_ALT_CLASS and current_verse:
inner_text = p.get_text().strip().upper()
if "PURPORT" in inner_text:
state = "PURPORT"
continue
elif "TRANSLATION" in inner_text:
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)
html = html.strip()
current_verse["translation"] = html
state = "POST_TRANSLATION"
continue
# --- Purport paragraphs (p48) ---
if (
cls == BODY_CLASS
and current_verse
and state in ("PURPORT", "POST_TRANSLATION")
):
state = "PURPORT"
html = decode_html_content(p)
if html:
current_verse["purport"].append(html)
continue
# --- Embedded Sanskrit verse in purport (p53) ---
if cls == QUOTED_VERSE_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
# Wrap as centered italic div for display
current_verse["purport"].append(
f'<div class="quoted-verse">{html}</div>'
)
continue
# --- Verse translation within purport (p44) ---
if cls == COMMENTATOR_CLASS and current_verse and state == "PURPORT":
html = decode_html_content(p)
if html:
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, blank sentinels become blank lines
trans_lines = []
for p in padas:
if p == "":
trans_lines.append("") # blank line between stanza groups
else:
trans_lines.append(p)
verse["transliteration"] = "\n".join(trans_lines)
# 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"]
# ---------------------------------------------------------------------------
# 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", "nbs")
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 NBS boundaries ---
nbs_start, nbs_end = find_nbs_boundaries(all_paragraphs)
print(f"NBS content: paragraphs {nbs_start} to {nbs_end}")
# --- Find section boundaries ---
sections = find_section_boundaries(all_paragraphs, nbs_start, nbs_end)
print(f"\nSection boundaries found:")
for name, idx in sorted(sections.items(), key=lambda x: x[1]):
print(f" [{idx}] {name}")
intro_bm = sections.get("introduction_bookmark")
first_verse = sections.get("first_verse")
if first_verse is None:
print("ERROR: Could not find first verse bookmark (NBS 1)!")
sys.exit(1)
# --- Extract intro segment ---
print("\n--- Extracting Intro Segment ---")
intro_segments = []
if intro_bm is not None:
intro_end = first_verse
intro_paras = extract_intro_segment(all_paragraphs, intro_bm, intro_end)
intro_segment = {
"slug": "introduction",
"title": "Introduction",
"type": "intro-segment",
"book": "nbs",
"order": 1,
"paragraphs": intro_paras,
}
intro_segments.append(intro_segment)
print(f" Introduction: {len(intro_paras)} paragraphs")
# --- Extract verses ---
print("\n--- Extracting Verses ---")
verses = extract_verses(all_paragraphs, first_verse, nbs_end)
print(f"Extracted {len(verses)} verses (expected 8: Sūtras 1-8)")
# --- Write intro.json ---
intro_meta = {
"book": "nbs",
"title": "Nārada-bhakti-sūtra",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1989,
"description": "The aphorisms of devotional service by Nārada Muni — translations and purports for sūtras 1–8.",
"totalChapters": 1,
"chapters": [
{
"number": 1,
"title": "The Value of Devotion",
"totalVerses": 8,
}
],
}
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["verse"]
filename = f"{str(text_num).zfill(2)}.json"
filepath = os.path.join(output_base, "chapters", "01", "verses", filename)
write_json(verse, filepath)
# --- Summary ---
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Book: Nārada-bhakti-sūtra")
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)}")
texts_found = sorted([v["verse"] for v in verses])
if texts_found:
print(
f" Sūtras: {texts_found[0]}-{texts_found[-1]} ({len(texts_found)} total)"
)
else:
print(" Sūtras: 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 (may be intentional)")
if issues:
print(" ISSUES FOUND:")
for issue in issues:
print(issue)
else:
print(
" All verses have transliteration, devanagari, synonyms, and translation ✓"
)
# Check expected verse count
expected_texts = set(range(1, 9))
found_texts = set(v["verse"] for v in verses)
missing = expected_texts - found_texts
extra = found_texts - expected_texts
if missing:
print(f" MISSING sūtras: {sorted(missing)}")
if extra:
print(f" UNEXPECTED sūtras: {sorted(extra)}")
if not missing and not extra:
print(f" All 8 sūtras found ✓")
# --- ScaGoudy spot checks ---
print(f"\n--- ScaGoudy Spot Checks ---")
for verse in verses:
ref = verse["reference"]
trans = verse["transliteration"]
# Check for leftover ScaGoudy characters
leftover = set()
for ch in trans:
if ch in SCAGOUDY_TO_IAST:
leftover.add(ch)
if leftover:
print(
f" WARNING: {ref} transliteration has leftover ScaGoudy chars: {leftover}"
)
else:
print(f" {ref}: transliteration clean ✓")
# --- Samples ---
print(f"\n--- Sample: Sūtra 1 (first) ---")
t1 = [v for v in verses if v["verse"] == 1]
if t1:
v = t1[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:100]}...")
print(f" Devanagari: {v['devanagari'][:100]}...")
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"])[:120]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Sūtra 4 (middle) ---")
t4 = [v for v in verses if v["verse"] == 4]
if t4:
v = t4[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:100]}...")
print(f" Devanagari: {v['devanagari'][:100]}...")
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"])[:120]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
print(f"\n--- Sample: Sūtra 8 (last) ---")
t8 = [v for v in verses if v["verse"] == 8]
if t8:
v = t8[0]
print(f" Reference: {v['reference']}")
print(f" Transliteration: {v['transliteration'][:100]}...")
print(f" Devanagari: {v['devanagari'][:100]}...")
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"])[:120]
print(f" Translation: {trans_preview}...")
print(f" Purport ¶s: {len(v['purport'])}")
# --- Intro segment details ---
print(f"\n--- Intro Segment Details ---")
for seg in intro_segments:
print(f" {seg['title']}: {len(seg['paragraphs'])} paragraphs")
if seg["paragraphs"]:
first_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][0])[:120]
print(f" First ¶: {first_preview}...")
last_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][-1])[:120]
print(f" Last ¶: {last_preview}...")
if __name__ == "__main__":
main()
+233
View File
@@ -0,0 +1,233 @@
/**
* seed-bs.ts — Push Śrī Brahma-saṁhitā JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-iso.ts) to avoid
* Prisma replica-set write requirements.
*
* BS is a verse book (like ISO) 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: "bs", data: <intro.json> }
* - verses → 62 verse docs (Texts 1-62)
* - prose_chapters → 2 intro segment docs (introduction, foreword)
*
* Usage:
* npx tsx scripts/seed-bs.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 BS_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "bs");
/** 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 BsIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type BsVerseJson = {
book_title: string;
chapter: number;
verse: number;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
type BsIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedBs(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(BS_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ BS intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<BsIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "bs" });
await metaCol.insertOne({ book: "bs", data: intro });
console.log(" 📖 BS book meta inserted");
// ---- 2. Verses ----
const versesCol = db.collection("verses");
await versesCol.deleteMany({ book: "bs" });
const verseDocs: Record<string, unknown>[] = [];
// Verse files: chapters/05/verses/01.json through 62.json
const versesDir = path.join(BS_DIR, "chapters", "05", "verses");
if (!fs.existsSync(versesDir)) {
console.error("❌ BS verses directory not found at", versesDir);
process.exit(1);
}
const verseFiles = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
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<BsVerseJson>(filePath);
verseDocs.push({
book: "bs",
division: null,
chapter: v.chapter,
verse: v.verse,
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: "bs" });
console.log(` ✅ BS verses: ${totalVerses} in MongoDB`);
// ---- 3. Prose intro segments ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "bs" });
const proseDocs: Record<string, unknown>[] = [];
const introSegmentFiles = ["introduction.json", "foreword.json"];
const introDir = path.join(BS_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<BsIntroSegmentJson>(filePath);
proseDocs.push({
book: "bs",
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: "bs" });
console.log(` ✅ BS prose: ${totalProse} entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Śrī Brahma-saṁhitā...\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 seedBs(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);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* seed-gg.ts — Push Gītār Gān JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
* Prisma replica-set write requirements.
*
* GG is a prose/poetry book (Bengali poetic edition of Bhagavad-gītā) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "gg", data: <intro.json> }
* - prose_chapters → intro segments + 18 chapters
*
* Usage:
* npx tsx scripts/seed-gg.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 GG_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "gg");
/** 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 GgIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type GgIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type GgChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedGg(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(GG_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ GG intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<GgIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "gg" });
await metaCol.insertOne({ book: "gg", data: intro });
console.log(" 📖 GG book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "gg" });
const docs: Record<string, unknown>[] = [];
// Intro segments (introduction)
const introDir = path.join(GG_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<GgIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "gg",
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 – 18.json)
const chaptersDir = path.join(GG_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<GgChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "gg",
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: "gg" });
console.log(` ✅ GG: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Gītār Gān...\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 seedGg(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);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* seed-lcl.ts — Push Life Comes from Life JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-pqpa.ts) to avoid
* Prisma replica-set write requirements.
*
* LCL is a prose/dialogue book (morning walk conversations) with no volume structure.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "lcl", data: <intro.json> }
* - prose_chapters → intro segments + 16 chapters
*
* Usage:
* npx tsx scripts/seed-lcl.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 LCL_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "lcl");
/** 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 LclIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type LclIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type LclChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedLcl(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(LCL_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ LCL intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<LclIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "lcl" });
await metaCol.insertOne({ book: "lcl", data: intro });
console.log(" 📖 LCL book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "lcl" });
const docs: Record<string, unknown>[] = [];
// Intro segments (foreword, introduction, about-the-author)
const introDir = path.join(LCL_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<LclIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "lcl",
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 – 16.json)
const chaptersDir = path.join(LCL_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<LclChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "lcl",
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: "lcl" });
console.log(` ✅ LCL: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Life Comes from Life...\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 seedLcl(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);
});
+203
View File
@@ -0,0 +1,203 @@
/**
* seed-lob.ts — Push Light of the Bhāgavata JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
* Prisma replica-set write requirements.
*
* LoB is a prose book with 48 "texts" (numbered chapters) plus 4 intro
* segments (front-pages, preface, acknowledgement, the-artist).
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "lob", data: <intro.json> }
* - prose_chapters → intro segments + 48 chapters
*
* Usage:
* npx tsx scripts/seed-lob.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 LOB_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "lob");
/** 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 LobIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type LobIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type LobChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedLob(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(LOB_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ LoB intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<LobIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "lob" });
await metaCol.insertOne({ book: "lob", data: intro });
console.log(" 📖 LoB book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "lob" });
const docs: Record<string, unknown>[] = [];
// Intro segments (front-pages, preface, acknowledgement, the-artist)
const introDir = path.join(LOB_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<LobIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "lob",
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 – 48.json)
const chaptersDir = path.join(LOB_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<LobChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "lob",
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: "lob" });
console.log(` ✅ LoB: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Light of the Bhāgavata...\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 seedLob(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-mm.ts — Push Mukunda-mālā-stotra JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-bs.ts) to avoid
* Prisma replica-set write requirements.
*
* MM is a verse book (like BS) with a single chapter and prose intro segments.
* We use volume=0 as the sentinel for "no volumes" on the prose side.
*
* Collections written:
* - book_meta → one doc { book: "mm", data: <intro.json> }
* - verses → 6 verse docs (Texts 1-6)
* - prose_chapters → 1 intro segment doc (introduction)
*
* Usage:
* npx tsx scripts/seed-mm.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 MM_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "mm");
/** 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 MmIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type MmVerseJson = {
book_title: string;
chapter: number;
verse: number;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
type MmIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedMm(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(MM_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ MM intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<MmIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "mm" });
await metaCol.insertOne({ book: "mm", data: intro });
console.log(" 📖 MM book meta inserted");
// ---- 2. Verses ----
const versesCol = db.collection("verses");
await versesCol.deleteMany({ book: "mm" });
const verseDocs: Record<string, unknown>[] = [];
// Verse files: chapters/01/verses/01.json through 06.json
const versesDir = path.join(MM_DIR, "chapters", "01", "verses");
if (!fs.existsSync(versesDir)) {
console.error("❌ MM verses directory not found at", versesDir);
process.exit(1);
}
const verseFiles = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
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<MmVerseJson>(filePath);
verseDocs.push({
book: "mm",
division: null,
chapter: v.chapter,
verse: v.verse,
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: "mm" });
console.log(` ✅ MM verses: ${totalVerses} in MongoDB`);
// ---- 3. Prose intro segments ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "mm" });
const proseDocs: Record<string, unknown>[] = [];
const introSegmentFiles = ["introduction.json"];
const introDir = path.join(MM_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<MmIntroSegmentJson>(filePath);
proseDocs.push({
book: "mm",
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: "mm" });
console.log(` ✅ MM prose: ${totalProse} entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Mukunda-mālā-stotra...\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 seedMm(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-nbs.ts — Push Nārada-bhakti-sūtra JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-mm.ts) to avoid
* Prisma replica-set write requirements.
*
* NBS is a verse book (like MM) with a single chapter and prose intro segments.
* We use volume=0 as the sentinel for "no volumes" on the prose side.
*
* Collections written:
* - book_meta → one doc { book: "nbs", data: <intro.json> }
* - verses → 8 verse docs (Sūtras 1-8)
* - prose_chapters → 1 intro segment doc (introduction)
*
* Usage:
* npx tsx scripts/seed-nbs.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 NBS_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "nbs");
/** 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 NbsIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type NbsVerseJson = {
book_title: string;
chapter: number;
verse: number;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
type NbsIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedNbs(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(NBS_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ NBS intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<NbsIntroJson>(introPath);
// ---- 1. Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "nbs" });
await metaCol.insertOne({ book: "nbs", data: intro });
console.log(" 📖 NBS book meta inserted");
// ---- 2. Verses ----
const versesCol = db.collection("verses");
await versesCol.deleteMany({ book: "nbs" });
const verseDocs: Record<string, unknown>[] = [];
// Verse files: chapters/01/verses/01.json through 08.json
const versesDir = path.join(NBS_DIR, "chapters", "01", "verses");
if (!fs.existsSync(versesDir)) {
console.error("❌ NBS verses directory not found at", versesDir);
process.exit(1);
}
const verseFiles = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
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<NbsVerseJson>(filePath);
verseDocs.push({
book: "nbs",
division: null,
chapter: v.chapter,
verse: v.verse,
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: "nbs" });
console.log(` ✅ NBS verses: ${totalVerses} in MongoDB`);
// ---- 3. Prose intro segments ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "nbs" });
const proseDocs: Record<string, unknown>[] = [];
const introSegmentFiles = ["introduction.json"];
const introDir = path.join(NBS_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<NbsIntroSegmentJson>(filePath);
proseDocs.push({
book: "nbs",
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: "nbs" });
console.log(` ✅ NBS prose: ${totalProse} entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Nārada-bhakti-sūtra...\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 seedNbs(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);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* seed-sva.ts — Push Songs of the Vaiṣṇava Ācāryas JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
* Prisma replica-set write requirements.
*
* SVA is a prose/song book with 4 parts (chapters) plus intro segments.
* We use volume=0 as the sentinel for "no volumes".
*
* Collections written:
* - book_meta → one doc { book: "sva", data: <intro.json> }
* - prose_chapters → intro segments + 4 chapters
*
* Usage:
* npx tsx scripts/seed-sva.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 SVA_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "sva");
/** 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 SvaIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type SvaIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type SvaChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedSva(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(SVA_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ SVA intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<SvaIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "sva" });
await metaCol.insertOne({ book: "sva", data: intro });
console.log(" 📖 SVA book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "sva" });
const docs: Record<string, unknown>[] = [];
// Intro segments (front-pages, foreword, introduction)
const introDir = path.join(SVA_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<SvaIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "sva",
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 – 04.json)
const chaptersDir = path.join(SVA_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<SvaChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "sva",
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: "sva" });
console.log(` ✅ SVA: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Songs of the Vaiṣṇava Ācāryas...\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 seedSva(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);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* seed-tlk.ts — Push Teachings of Lord Kapila JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
* Prisma replica-set write requirements.
*
* TLK 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: "tlk", data: <intro.json> }
* - prose_chapters → intro segments + 18 chapters
*
* Usage:
* npx tsx scripts/seed-tlk.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 TLK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "tlk");
/** 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 TlkIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type TlkIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type TlkChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedTlk(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(TLK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ TLK intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<TlkIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "tlk" });
await metaCol.insertOne({ book: "tlk", data: intro });
console.log(" 📖 TLK book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "tlk" });
const docs: Record<string, unknown>[] = [];
// Intro segments (front-pages, foreword, about-the-author, credits, back-flap)
const introDir = path.join(TLK_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<TlkIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "tlk",
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 – 18.json)
const chaptersDir = path.join(TLK_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<TlkChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "tlk",
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: "tlk" });
console.log(` ✅ TLK: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Teachings of Lord Kapila...\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 seedTlk(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);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* seed-tqk.ts — Push Teachings of Queen Kuntī JSON data into MongoDB.
*
* Uses the native MongoDB driver (same pattern as seed-tlk.ts) to avoid
* Prisma replica-set write requirements.
*
* TQK 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: "tqk", data: <intro.json> }
* - prose_chapters → intro segments + 26 chapters
*
* Usage:
* npx tsx scripts/seed-tqk.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 TQK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "tqk");
/** 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 TqkIntroJson = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: { number: number; title: string }[];
};
type TqkIntroSegmentJson = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
type TqkChapterJson = {
book: string;
chapter: number | null;
title: string;
type: string;
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// Seed function
// ---------------------------------------------------------------------------
async function seedTqk(db: ReturnType<MongoClient["db"]>) {
const introPath = path.join(TQK_DIR, "intro.json");
if (!fs.existsSync(introPath)) {
console.error("❌ TQK intro.json not found at", introPath);
process.exit(1);
}
const intro = readJson<TqkIntroJson>(introPath);
// ---- Book meta ----
const metaCol = db.collection("book_meta");
await metaCol.deleteMany({ book: "tqk" });
await metaCol.insertOne({ book: "tqk", data: intro });
console.log(" 📖 TQK book meta inserted");
// ---- Prose chapters collection ----
const proseCol = db.collection("prose_chapters");
await proseCol.deleteMany({ book: "tqk" });
const docs: Record<string, unknown>[] = [];
// Intro segments (front-pages, introduction, back-flap)
const introDir = path.join(TQK_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<TqkIntroSegmentJson>(path.join(introDir, file));
docs.push({
book: "tqk",
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 – 26.json)
const chaptersDir = path.join(TQK_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<TqkChapterJson>(path.join(chaptersDir, file));
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
docs.push({
book: "tqk",
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: "tqk" });
console.log(` ✅ TQK: ${totalDocs} prose entries in MongoDB`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("🌱 Seeding Teachings of Queen Kuntī...\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 seedTqk(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
+858
View File
@@ -0,0 +1,858 @@
#!/usr/bin/env python3
"""Extract Teachings of Lord Kapila, the Son of Devahūti (1977) from other.html.
The book has 5 intro/back-matter segments and 18 chapters.
Intro segments:
front-pages (order 1)
foreword (order 2)
about-the-author (order 97)
credits (order 98)
back-flap (order 99)
Output:
lib/jsons/books/tlk/intro.json — Book metadata
lib/jsons/books/tlk/intro/*.json — Intro segments
lib/jsons/books/tlk/chapters/*.json — Chapters (01–18)
Usage:
python3 scripts/tlk_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 TLK
# ---------------------------------------------------------------------------
# Book title header class (bold, <p class="p12">)
BOOK_HEADER_CLASS = "p12"
# Bookmark class — used for section boundary detection
BOOKMARK_CLASS = "p13"
# Chapter heading (bold, e.g. "CHAPTER ONE\nThe Purpose of Lord Kapila's Advent")
CHAPTER_HEADING_CLASS = "p27"
# Section heading (bold, e.g. "Foreword", "Credits")
SECTION_HEADING_CLASS = "p31"
# Body paragraphs — THE main content class
BODY_CLASS = "p6"
# Sanskrit verse text (italic, multi-line with <br>)
SANSKRIT_VERSE_CLASS = "p53"
# Verse translation/reference text (after Sanskrit verses, mid-purport quotes)
VERSE_TRANSLATION_CLASS = "p44"
# Internal verse bookmark (e.g. "TLK Vs 1", "TLK Vs 2")
VERSE_BOOKMARK_CLASS = "p49"
# TEXT N / TRANSLATION / PURPORT headers (bold)
STRUCTURED_HEADER_CLASS = "p55"
# Translation text (bold — verse translation after TRANSLATION header)
TRANSLATION_TEXT_CLASS = "p211"
# Verse reference (e.g. [Śrīmad-Bhāgavatam 3.25.1])
VERSE_REFERENCE_CLASS = "p212"
# Publication info text
PUBLICATION_INFO_CLASS = "p128"
# Attribution line (e.g. "—The Publishers")
ATTRIBUTION_CLASS = "p129"
# Block quote (bold, testimonials in Credits section)
BLOCKQUOTE_CLASS = "p47"
# Attribution for block quotes
QUOTE_ATTRIBUTION_CLASS = "p42"
# Jacket/design credits
JACKET_CREDITS_CLASS = "p3"
# Classes to skip entirely (empty/break)
SKIP_CLASSES = frozenset(["p15"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_tlk_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the TLK content.
Start: <p class="p12"> containing "Teachings of Lord Kapila"
End: next <p class="p12"> which is "Teachings of Queen Kunté" (or similar)
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = decode_text(p)
# Find start
if start_idx is None:
if cls == BOOK_HEADER_CLASS and "Teachings of Lord Kapila" in text:
start_idx = i
continue
# Find end: the next p12 book header after our start
if cls == BOOK_HEADER_CLASS:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Teachings of Lord Kapila' 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 TLK bookmark tags in the TLK range.
Bookmarks are p13 class with text starting with "TLK".
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:
continue
text = decode_text(p).strip()
if text.startswith("TLK"):
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str):
"""Parse a chapter bookmark like 'TLK 1: The Purpose of Lord Kapila's Advent'.
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"TLK\s+(\d+):\s+(.+)", text.strip())
if m:
num = int(m.group(1))
title = m.group(2).strip()
return num, title
return None
def parse_intro_bookmark(text: str):
"""Check if this is a front-pages or foreword bookmark.
Returns the slug or None.
"""
stripped = text.strip()
# "TLK Into" is the front-pages bookmark (note: "Into" not "Intro")
if stripped == "TLK Into":
return "front-pages"
if stripped == "TLK Foreword":
return "foreword"
return None
def parse_backmatter_bookmark(text: str):
"""Check if this is a back-matter bookmark.
Returns the slug or None.
"""
stripped = text.strip()
if stripped == "TLK The Author":
return "about-the-author"
if stripped == "TLK Credits":
return "credits"
if stripped == "TLK Back Flap":
return "back-flap"
return None
INTRO_SLUG_ORDER = {
"front-pages": 1,
"foreword": 2,
}
BACKMATTER_SLUG_ORDER = {
"about-the-author": 97,
"credits": 98,
"back-flap": 99,
}
INTRO_SLUG_TITLE = {
"front-pages": "Front Pages",
"foreword": "Foreword",
"about-the-author": "About the Author",
"credits": "Credits",
"back-flap": "Back Flap",
}
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list:
"""Extract paragraphs for a section (chapter, intro segment).
Handles all CSS classes within the TLK content.
Handles p53 Sanskrit verses as centered italic divs.
Handles structured verse headers (TEXT N, TRANSLATION, PURPORT).
"""
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/break 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
# Skip internal verse bookmarks (p49 containing "TLK Vs")
if cls == VERSE_BOOKMARK_CLASS:
text = decode_text(p)
if "TLK Vs" in text or "TLK" in text:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse text (p53) — italic, multi-line with <br>
if cls == SANSKRIT_VERSE_CLASS:
html = decode_html_content(p)
if html:
# Strip outer <i> tags, then re-wrap in centered italic div
inner = re.sub(r"</?i>", "", html)
result.append(f'<div style="text-align:center"><i>{inner}</i></div>')
continue
# Structured headers: TEXT N, TRANSLATION, PURPORT (p55)
if cls == STRUCTURED_HEADER_CLASS:
html = decode_html_content(p)
if html:
# These are already bold in the source; ensure bold wrapping
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Translation text (p211) — bold verse translation
if cls == TRANSLATION_TEXT_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Verse reference (p212) — e.g. [Śrīmad-Bhāgavatam 3.25.1]
if cls == VERSE_REFERENCE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6) — the main content class
if cls == BODY_CLASS:
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
# Verse translation text (p44) — after Sanskrit verses mid-purport
if cls == VERSE_TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Chapter heading (p27) — bold chapter title
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Section heading (p31) — bold section title
if cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Publication info (p128)
if cls == PUBLICATION_INFO_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Attribution (p129) — e.g. "—The Publishers"
if cls == ATTRIBUTION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Block quotes (p47) — testimonials in Credits section
if cls == BLOCKQUOTE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Quote attribution (p42)
if cls == QUOTE_ATTRIBUTION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Jacket/design credits (p3)
if cls == JACKET_CREDITS_CLASS:
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)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_tlk(soup: BeautifulSoup) -> dict:
"""Extract the entire Teachings of Lord Kapila 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 TLK boundaries
tlk_start, tlk_end = find_tlk_boundaries(all_paragraphs)
print(f" TLK content: paragraphs {tlk_start} to {tlk_end}")
print(f" Total paragraphs in range: {tlk_end - tlk_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, tlk_start, tlk_end)
print(f" Found {len(bookmarks)} TLK bookmarks")
for pos, text in bookmarks:
print(f" Para ~{pos}: {text}")
# Step 3: Categorize bookmarks
intro_bookmarks = {} # slug -> paragraph index
backmatter_bookmarks = {} # slug -> paragraph index
chapter_bookmarks = [] # (para_idx, chapter_num, title)
for pos, text in bookmarks:
# Check chapter
ch = parse_chapter_bookmark(text)
if ch:
chapter_bookmarks.append((pos, ch[0], ch[1]))
continue
# Check intro (front-pages, foreword)
intro_slug = parse_intro_bookmark(text)
if intro_slug:
intro_bookmarks[intro_slug] = pos
continue
# Check back matter (about-the-author, credits, back-flap)
bm_slug = parse_backmatter_bookmark(text)
if bm_slug:
backmatter_bookmarks[bm_slug] = pos
continue
print(f" WARNING: Unrecognized bookmark: {text}")
print(f" Intro bookmarks: {list(intro_bookmarks.keys())}")
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
print(f" Back matter bookmarks: {list(backmatter_bookmarks.keys())}")
# Build an ordered list of all section start positions for boundary computation
all_section_starts = sorted(
list(intro_bookmarks.values())
+ [pos for pos, _, _ in chapter_bookmarks]
+ list(backmatter_bookmarks.values())
)
def next_section_after(pos):
"""Return the next section start after `pos`, or tlk_end."""
for s in all_section_starts:
if s > pos:
return s
return tlk_end
# Step 4: Extract intro segments (front-pages, foreword)
intro_segments = []
for slug in ["front-pages", "foreword"]:
if slug in intro_bookmarks:
bm_pos = intro_bookmarks[slug]
content_start = bm_pos + 1
content_end = next_section_after(bm_pos)
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
seg = {
"slug": slug,
"title": INTRO_SLUG_TITLE[slug],
"type": "intro-segment",
"book": "tlk",
"order": INTRO_SLUG_ORDER[slug],
"paragraphs": paras,
}
intro_segments.append(seg)
# Step 5: Extract chapters 1–18
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
content_end = next_section_after(ch_pos)
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "tlk",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 6: Extract back matter segments (about-the-author, credits, back-flap)
backmatter_segments = []
for slug in ["about-the-author", "credits", "back-flap"]:
if slug in backmatter_bookmarks:
bm_pos = backmatter_bookmarks[slug]
content_start = bm_pos + 1
content_end = next_section_after(bm_pos)
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
seg = {
"slug": slug,
"title": INTRO_SLUG_TITLE[slug],
"type": "intro-segment",
"book": "tlk",
"order": BACKMATTER_SLUG_ORDER[slug],
"paragraphs": paras,
}
backmatter_segments.append(seg)
# Combine all intro segments (front + back matter)
all_intro_segments = intro_segments + backmatter_segments
# Step 7: Build chapter listing for intro.json
chapter_listing = [
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
]
return {
"intro_segments": all_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", "tlk")
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 Kapila, the Son of Devahūti (1977)...\n")
result = extract_tlk(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "tlk",
"title": "Teachings of Lord Kapila, the Son of Devahūti",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1977,
"description": "Lord Kapila's teachings on Sāṅkhya philosophy and bhakti-yoga to His mother Devahūti.",
"totalChapters": 18,
"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 / Back-Matter 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']:>2}. {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/back-matter segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Chapters: {len(chapters)}")
print(f" Total paragraphs: {total_paras}")
print(f" Intro/back: {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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 9:
print(f"\n--- Sample: Chapter 9 ---")
ch9 = [c for c in chapters if c["chapter"] == 9]
if ch9:
for i, p in enumerate(ch9[0]["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 18:
print(f"\n--- Sample: Chapter 18 (last) ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 18 chapters present
expected_chapters = set(range(1, 19))
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 18 chapters present")
# Check intro segments
expected_intros = {
"front-pages",
"foreword",
"about-the-author",
"credits",
"back-flap",
}
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 5 intro/back-matter 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 (0 paragraphs): {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding — look for common encoded characters
encoded_chars = set("äåöëòçüéàùè")
suspect_count = 0
all_sections = chapters + intro_segments
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 (centered italic divs)
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} Sanskrit verse blocks in chapters")
# Count structured verse headers (TEXT N, TRANSLATION, PURPORT)
text_n_count = 0
translation_header_count = 0
purport_header_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p).strip()
if re.match(r"TEXT\s+\d+", plain):
text_n_count += 1
elif plain == "TRANSLATION":
translation_header_count += 1
elif plain == "PURPORT":
purport_header_count += 1
print(f" ℹ TEXT N headers: {text_n_count}")
print(f" ℹ TRANSLATION headers: {translation_header_count}")
print(f" ℹ PURPORT headers: {purport_header_count}")
# ScaGoudy decoding spot check
print(f"\n--- ScaGoudy Decoding Spot Check ---")
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"][:20])
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'")
else:
print(f" ℹ 'Prabhupāda' not found in Chapter 1 first 20 paragraphs")
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 20 paragraphs (may be normal)"
)
# Check for Kapila
if "Kapila" in ch1_text:
print(f" ✅ 'Kapila' found in Chapter 1")
# Check for Devahūti
if "Devahūti" in ch1_text:
print(f" ✅ 'Devahūti' correctly decoded")
elif "Devahüti" in ch1_text:
print(f" ⚠ 'Devahüti' still encoded")
# Check for Sāṅkhya
if "Sāṅkhya" in ch1_text or "sāṅkhya" in ch1_text or "Sāṇkhya" in ch1_text:
print(f" ✅ 'Sāṅkhya' correctly decoded")
# Sample verse blocks
print(f"\n--- Sample Sanskrit 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" Ch {ch['chapter']}: {preview}...")
shown += 1
if shown >= 3:
break
if shown >= 3:
break
# Sample structured verse sections (TEXT N + TRANSLATION)
print(f"\n--- Sample Structured Verse Sections ---")
shown = 0
for ch in chapters[:3]:
for i, p in enumerate(ch["paragraphs"]):
plain = re.sub(r"<[^>]+>", "", p).strip()
if re.match(r"TEXT\s+\d+", plain) and shown < 3:
# Show TEXT header and next few paragraphs
print(f" Ch {ch['chapter']}:")
for j in range(i, min(i + 5, len(ch["paragraphs"]))):
pj_preview = re.sub(r"<[^>]+>", "", ch["paragraphs"][j])[:100]
print(f" [{j}] {pj_preview}...")
shown += 1
print()
if shown >= 3:
break
# Check intro segments content
print(f"--- Intro Segment Samples ---")
for seg in intro_segments:
print(f"\n {seg['title']} ({seg['slug']}, order {seg['order']}):")
for i, p in enumerate(seg["paragraphs"][:2]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+864
View File
@@ -0,0 +1,864 @@
#!/usr/bin/env python3
"""Extract Teachings of Queen Kuntī (1978) from other.html.
The book has 3 intro/back-matter segments and 26 chapters.
Intro segments:
front-pages (order 1)
introduction (order 2)
back-flap (order 99)
Output:
lib/jsons/books/tqk/intro.json — Book metadata
lib/jsons/books/tqk/intro/*.json — Intro segments
lib/jsons/books/tqk/chapters/*.json — Chapters (01–26)
Usage:
python3 scripts/tqk_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 TQK
# ---------------------------------------------------------------------------
# Book title header class (bold, <p class="p12">)
BOOK_HEADER_CLASS = "p12"
# Bookmark class — used for section boundary detection
BOOKMARK_CLASS = "p13"
# Chapter heading (bold, e.g. "1. The Original Person")
CHAPTER_HEADING_CLASS = "p27"
# Section heading (bold, e.g. "Front flap", "Introduction", "Back flap")
SECTION_HEADING_CLASS = "p31"
# Body paragraphs — THE main content class
BODY_CLASS = "p6"
# Sanskrit verse text (italic, multi-line with <br>)
SANSKRIT_VERSE_CLASS = "p53"
# Verse translation/reference text (after Sanskrit verses, mid-purport quotes)
VERSE_TRANSLATION_CLASS = "p44"
# TEXT N / TRANSLATION / PURPORT headers (bold)
STRUCTURED_HEADER_CLASS = "p55"
# Translation text (bold — verse translation after TRANSLATION header)
TRANSLATION_TEXT_CLASS = "p211"
# Verse reference (e.g. —Śrīmad-Bhāgavatam 1.8.18)
VERSE_REFERENCE_CLASS_212 = "p212"
VERSE_REFERENCE_CLASS_216 = "p216"
# Short text lines
SHORT_TEXT_CLASS = "p118"
# Italic/centered text (e.g. block quotes in introduction)
CENTERED_ITALIC_CLASS = "p25"
BLOCK_QUOTE_CLASS_23 = "p23"
# Publication info text
PUBLICATION_INFO_CLASS_214 = "p214"
PUBLICATION_INFO_CLASS_215 = "p215"
# Testimonial quotes (back flap)
TESTIMONIAL_QUOTE_CLASS = "p18"
# Testimonial attribution (back flap)
TESTIMONIAL_ATTRIBUTION_CLASS = "p147"
# Jacket/design credits
JACKET_CREDITS_CLASS_3 = "p3"
JACKET_CREDITS_CLASS_21 = "p21"
# Glossary heading (skip)
GLOSSARY_CLASS = "p19"
# Classes to skip entirely (empty/break)
SKIP_CLASSES = frozenset(["p15"])
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def find_tqk_boundaries(all_paragraphs: list) -> tuple:
"""Find the start and end paragraph indices of the TQK content.
Start: <p class="p12"> containing "Teachings of Queen Kunté"
End: next <p class="p12"> after TQK start (which is "Renunciation Through Wisdom")
or end of file if TQK is the last book.
"""
start_idx = None
end_idx = len(all_paragraphs)
for i, p in enumerate(all_paragraphs):
cls = get_css_class(p)
text = decode_text(p)
# Find start
if start_idx is None:
if cls == BOOK_HEADER_CLASS and "Teachings of Queen Kunt" in text:
start_idx = i
continue
# Find end: the next p12 book header after our start
if cls == BOOK_HEADER_CLASS:
end_idx = i
break
if start_idx is None:
print("ERROR: Could not find 'Teachings of Queen Kunté' 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 TQK bookmark tags in the TQK range.
Bookmarks are p13 class with text starting with "TQK".
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:
continue
text = decode_text(p).strip()
if text.startswith("TQK"):
bookmarks.append((i, text))
return bookmarks
def parse_chapter_bookmark(text: str):
"""Parse a chapter bookmark like 'TQK 1: The Original Person'.
Returns (chapter_number, title) or None if not a chapter bookmark.
"""
m = re.match(r"TQK\s+(\d+):\s+(.+)", text.strip())
if m:
num = int(m.group(1))
title = m.group(2).strip()
return num, title
return None
def parse_intro_bookmark(text: str):
"""Check if this is a front-pages or introduction bookmark.
Returns the slug or None.
"""
stripped = text.strip()
if stripped == "TQK Front flap":
return "front-pages"
if stripped == "TQK Introduction":
return "introduction"
return None
def parse_backmatter_bookmark(text: str):
"""Check if this is a back-matter bookmark.
Returns the slug or None.
"""
stripped = text.strip()
if stripped == "TQK Back flap":
return "back-flap"
return None
INTRO_SLUG_ORDER = {
"front-pages": 1,
"introduction": 2,
}
BACKMATTER_SLUG_ORDER = {
"back-flap": 99,
}
INTRO_SLUG_TITLE = {
"front-pages": "Front Pages",
"introduction": "Introduction",
"back-flap": "Back Flap",
}
def extract_paragraphs(
all_paragraphs: list,
start_idx: int,
end_idx: int,
) -> list:
"""Extract paragraphs for a section (chapter, intro segment).
Handles all CSS classes within the TQK content.
Handles p53 Sanskrit verses as centered italic divs.
Handles structured verse headers (TEXT N, TRANSLATION, PURPORT).
"""
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/break 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
# Skip glossary heading (p19)
if cls == GLOSSARY_CLASS:
continue
# Check for dash lines — skip them
text = decode_text(p)
if is_dash_line(text):
continue
# Sanskrit verse text (p53) — italic, multi-line with <br>
if cls == SANSKRIT_VERSE_CLASS:
html = decode_html_content(p)
if html:
# Strip outer <i> tags, then re-wrap in centered italic div
inner = re.sub(r"</?i>", "", html)
result.append(f'<div style="text-align:center"><i>{inner}</i></div>')
continue
# Structured headers: TEXT N, TRANSLATION, PURPORT (p55)
if cls == STRUCTURED_HEADER_CLASS:
html = decode_html_content(p)
if html:
# These are already bold in the source; ensure bold wrapping
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Translation text (p211) — bold verse translation
if cls == TRANSLATION_TEXT_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Verse reference (p212 or p216) — e.g. —Śrīmad-Bhāgavatam 1.8.18
if cls in (VERSE_REFERENCE_CLASS_212, VERSE_REFERENCE_CLASS_216):
html = decode_html_content(p)
if html:
result.append(html)
continue
# Body paragraphs (p6) — the main content class
if cls == BODY_CLASS:
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
# Verse translation text (p44) — after Sanskrit verses mid-purport
if cls == VERSE_TRANSLATION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Chapter heading (p27) — bold chapter title
if cls == CHAPTER_HEADING_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Section heading (p31) — bold section title
if cls == SECTION_HEADING_CLASS:
html = decode_html_content(p)
if html:
if not html.strip().startswith("<b>"):
html = f"<b>{html}</b>"
result.append(html)
continue
# Short text lines (p118)
if cls == SHORT_TEXT_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Italic/centered text (p25) — e.g. catalog note, publisher address
if cls == CENTERED_ITALIC_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Block quotes (p23) — indented quotes in introduction
if cls == BLOCK_QUOTE_CLASS_23:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Publication info (p214, p215)
if cls in (PUBLICATION_INFO_CLASS_214, PUBLICATION_INFO_CLASS_215):
html = decode_html_content(p)
if html:
result.append(html)
continue
# Testimonial quotes (p18) — back flap
if cls == TESTIMONIAL_QUOTE_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Testimonial attribution (p147) — back flap
if cls == TESTIMONIAL_ATTRIBUTION_CLASS:
html = decode_html_content(p)
if html:
result.append(html)
continue
# Jacket/design credits (p3, p21)
if cls in (JACKET_CREDITS_CLASS_3, JACKET_CREDITS_CLASS_21):
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)
return result
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_tqk(soup: BeautifulSoup) -> dict:
"""Extract the entire Teachings of Queen Kuntī 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 TQK boundaries
tqk_start, tqk_end = find_tqk_boundaries(all_paragraphs)
print(f" TQK content: paragraphs {tqk_start} to {tqk_end}")
print(f" Total paragraphs in range: {tqk_end - tqk_start}")
# Step 2: Find all bookmarks
bookmarks = find_all_bookmarks(all_paragraphs, tqk_start, tqk_end)
print(f" Found {len(bookmarks)} TQK bookmarks")
for pos, text in bookmarks:
print(f" Para ~{pos}: {text}")
# Step 3: Categorize bookmarks
intro_bookmarks = {} # slug -> paragraph index
backmatter_bookmarks = {} # slug -> paragraph index
chapter_bookmarks = [] # (para_idx, chapter_num, title)
for pos, text in bookmarks:
# Check chapter
ch = parse_chapter_bookmark(text)
if ch:
chapter_bookmarks.append((pos, ch[0], ch[1]))
continue
# Check intro (front-pages, introduction)
intro_slug = parse_intro_bookmark(text)
if intro_slug:
intro_bookmarks[intro_slug] = pos
continue
# Check back matter (back-flap)
bm_slug = parse_backmatter_bookmark(text)
if bm_slug:
backmatter_bookmarks[bm_slug] = pos
continue
print(f" WARNING: Unrecognized bookmark: {text}")
print(f" Intro bookmarks: {list(intro_bookmarks.keys())}")
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
print(f" Back matter bookmarks: {list(backmatter_bookmarks.keys())}")
# Build an ordered list of all section start positions for boundary computation
all_section_starts = sorted(
list(intro_bookmarks.values())
+ [pos for pos, _, _ in chapter_bookmarks]
+ list(backmatter_bookmarks.values())
)
def next_section_after(pos):
"""Return the next section start after `pos`, or tqk_end."""
for s in all_section_starts:
if s > pos:
return s
return tqk_end
# Step 4: Extract intro segments (front-pages, introduction)
intro_segments = []
for slug in ["front-pages", "introduction"]:
if slug in intro_bookmarks:
bm_pos = intro_bookmarks[slug]
content_start = bm_pos + 1
content_end = next_section_after(bm_pos)
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
seg = {
"slug": slug,
"title": INTRO_SLUG_TITLE[slug],
"type": "intro-segment",
"book": "tqk",
"order": INTRO_SLUG_ORDER[slug],
"paragraphs": paras,
}
intro_segments.append(seg)
# Step 5: Extract chapters 1–26
chapters = []
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
content_start = ch_pos + 1
content_end = next_section_after(ch_pos)
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
ch = {
"book": "tqk",
"chapter": ch_num,
"title": ch_title,
"type": "chapter",
"paragraphs": paragraphs,
}
chapters.append(ch)
# Step 6: Extract back matter segments (back-flap)
backmatter_segments = []
for slug in ["back-flap"]:
if slug in backmatter_bookmarks:
bm_pos = backmatter_bookmarks[slug]
content_start = bm_pos + 1
content_end = next_section_after(bm_pos)
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
seg = {
"slug": slug,
"title": INTRO_SLUG_TITLE[slug],
"type": "intro-segment",
"book": "tqk",
"order": BACKMATTER_SLUG_ORDER[slug],
"paragraphs": paras,
}
backmatter_segments.append(seg)
# Combine all intro segments (front + back matter)
all_intro_segments = intro_segments + backmatter_segments
# Step 7: Build chapter listing for intro.json
chapter_listing = [
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
]
return {
"intro_segments": all_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", "tqk")
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 Queen Kuntī (1978)...\n")
result = extract_tqk(soup)
intro_segments = result["intro_segments"]
chapters = result["chapters"]
chapter_listing = result["chapter_listing"]
# --- Write intro.json ---
intro_meta = {
"book": "tqk",
"title": "Teachings of Queen Kuntī",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1978,
"description": "Queen Kuntī's prayers to Lord Kṛṣṇa from Śrīmad-Bhāgavatam with commentary by Śrīla Prabhupāda.",
"totalChapters": 26,
"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 / Back-Matter 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']:>2}. {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/back-matter segments: {len(intro_segments)}")
for seg in intro_segments:
print(
f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
)
print(f" Chapters: {len(chapters)}")
print(f" Total paragraphs: {total_paras}")
print(f" Intro/back: {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"][:5]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 13:
print(f"\n--- Sample: Chapter 13 ---")
ch13 = [c for c in chapters if c["chapter"] == 13]
if ch13:
for i, p in enumerate(ch13[0]["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
if len(chapters) >= 26:
print(f"\n--- Sample: Chapter 26 (last) ---")
last = chapters[-1]
for i, p in enumerate(last["paragraphs"][:3]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
# --- Quality Checks ---
print(f"\n--- Quality Checks ---")
# Check all 26 chapters present
expected_chapters = set(range(1, 27))
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 26 chapters present")
# Check intro segments
expected_intros = {
"front-pages",
"introduction",
"back-flap",
}
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 3 intro/back-matter 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 (0 paragraphs): {empty_intros}")
else:
print(f" ✅ No empty intro segments")
# Check ScaGoudy decoding — look for common encoded characters
encoded_chars = set("äåöëòçüéàùè")
suspect_count = 0
all_sections = chapters + intro_segments
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 (centered italic divs)
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} Sanskrit verse blocks in chapters")
# Count structured verse headers (TEXT N, TRANSLATION, PURPORT)
text_n_count = 0
translation_header_count = 0
purport_header_count = 0
for ch in chapters:
for p in ch["paragraphs"]:
plain = re.sub(r"<[^>]+>", "", p).strip()
if re.match(r"TEXT\s+\d+", plain):
text_n_count += 1
elif plain == "TRANSLATION":
translation_header_count += 1
elif plain == "PURPORT":
purport_header_count += 1
print(f" ℹ TEXT N headers: {text_n_count}")
print(f" ℹ TRANSLATION headers: {translation_header_count}")
print(f" ℹ PURPORT headers: {purport_header_count}")
# ScaGoudy decoding spot check
print(f"\n--- ScaGoudy Decoding Spot Check ---")
if chapters:
ch1_text = " ".join(chapters[0]["paragraphs"][:20])
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'")
else:
print(f" ℹ 'Prabhupāda' not found in Chapter 1 first 20 paragraphs")
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 20 paragraphs (may be normal)"
)
# Check for Kuntī
if "Kuntī" in ch1_text or "Kuntīdevī" in ch1_text:
print(f" ✅ 'Kuntī' correctly decoded")
elif "Kunté" in ch1_text or "Kuntédevé" in ch1_text:
print(f" ⚠ 'Kunté' still encoded")
# Check for Bhāgavatam
if "Bhāgavatam" in ch1_text:
print(f" ✅ 'Bhāgavatam' correctly decoded")
elif "Bhägavatam" in ch1_text:
print(f" ⚠ 'Bhägavatam' still encoded")
# Sample verse blocks
print(f"\n--- Sample Sanskrit 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" Ch {ch['chapter']}: {preview}...")
shown += 1
if shown >= 3:
break
if shown >= 3:
break
# Sample structured verse sections (TRANSLATION)
print(f"\n--- Sample Structured Verse Sections ---")
shown = 0
for ch in chapters[:3]:
for i, p in enumerate(ch["paragraphs"]):
plain = re.sub(r"<[^>]+>", "", p).strip()
if plain == "TRANSLATION" and shown < 3:
# Show TRANSLATION header and next few paragraphs
print(f" Ch {ch['chapter']}:")
start_show = max(i - 1, 0)
for j in range(start_show, min(i + 4, len(ch["paragraphs"]))):
pj_preview = re.sub(r"<[^>]+>", "", ch["paragraphs"][j])[:100]
print(f" [{j}] {pj_preview}...")
shown += 1
print()
if shown >= 3:
break
# Check intro segments content
print(f"--- Intro Segment Samples ---")
for seg in intro_segments:
print(f"\n {seg['title']} ({seg['slug']}, order {seg['order']}):")
for i, p in enumerate(seg["paragraphs"][:2]):
preview = re.sub(r"<[^>]+>", "", p)[:120]
print(f" [{i}] {preview}...")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()