feat: sva extracted and seeded
This commit is contained in:
+348
-205
@@ -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.
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Light of the Bhā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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user