feat: add ssr
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
# Prabhupāda Says — Project Context
|
||||
|
||||
## What this project is
|
||||
|
||||
A Next.js app that converts legacy RTF files containing Vedic scriptures (Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, Kṛṣṇa Book, letters, magazines, etc.) into structured, browsable content with proper Unicode Sanskrit.
|
||||
|
||||
The original RTF files use **custom font encodings** (Indevr, ScaGoudy, Inbenr/Inbeni, RM Devanagari, Balaram, etc.) where ASCII characters map to Devanagari/Bengali glyphs and IAST diacritical marks.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 16** with Turbopack, React 19, TypeScript
|
||||
- **Tailwind CSS v4** with `@tailwindcss/postcss`
|
||||
- **shadcn/ui** (base-nova style, neutral base color, lucide icons) — components: Button, Card, Badge, Tooltip, Input
|
||||
- **next-themes** for dark/light mode
|
||||
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
|
||||
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
|
||||
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
|
||||
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
spsays-training/
|
||||
├── app/
|
||||
│ ├── page.tsx → "Prabhupāda Says" landing page (Books, Glossary, RTFs)
|
||||
│ ├── layout.tsx → ThemeProvider + TooltipProvider wrapper
|
||||
│ ├── globals.css → shadcn/Tailwind v4 CSS with dark mode
|
||||
│ ├── books/
|
||||
│ │ ├── page.tsx → Books index (BG, SB, CC, SB1962, KB)
|
||||
│ │ ├── chapter-viewer.tsx → Shared client component for verse-based books
|
||||
│ │ ├── extracted-verse-count.tsx → (i) tooltip for verse counts
|
||||
│ │ ├── bg/ → Bhagavad-gītā pages
|
||||
│ │ │ ├── page.tsx → Chapter list
|
||||
│ │ │ └── [chapter]/
|
||||
│ │ │ ├── page.tsx → Redirects to first verse
|
||||
│ │ │ └── [verse]/page.tsx → Single verse viewer
|
||||
│ │ ├── sb/ → Śrīmad-Bhāgavatam pages
|
||||
│ │ │ ├── page.tsx → Canto list
|
||||
│ │ │ └── [canto]/
|
||||
│ │ │ ├── page.tsx → Chapter list
|
||||
│ │ │ └── [chapter]/
|
||||
│ │ │ ├── page.tsx → Redirects to first verse
|
||||
│ │ │ └── [verse]/page.tsx → Single verse viewer
|
||||
│ │ ├── cc/ → Caitanya-caritāmṛta pages (same pattern, [lila]/[chapter]/[verse])
|
||||
│ │ ├── sb1962/ → 1962 SB First Canto (same pattern as BG, [chapter]/[verse])
|
||||
│ │ └── kb/ → Kṛṣṇa Book (prose, no verses)
|
||||
│ │ ├── page.tsx → Volume list
|
||||
│ │ └── [volume]/
|
||||
│ │ ├── page.tsx → Chapter list + intro segments
|
||||
│ │ └── [slug]/page.tsx → Reading page with sidebar
|
||||
│ ├── glossary/
|
||||
│ │ ├── page.tsx → Server component wrapper
|
||||
│ │ └── glossary-client.tsx → Searchable client component
|
||||
│ ├── rtfs/
|
||||
│ │ ├── page.tsx → RTF file listing (gated by ALLOW_RTF env)
|
||||
│ │ └── [slug]/page.tsx → RTF iframe viewer (gated by ALLOW_RTF env)
|
||||
│ └── api/
|
||||
│ ├── books/
|
||||
│ │ ├── route.ts → GET all book metas
|
||||
│ │ └── [book]/
|
||||
│ │ ├── route.ts → GET single book meta
|
||||
│ │ └── verses/route.ts → GET verses (?chapter=&verse=&division=&indexOnly=&page=&limit=)
|
||||
│ └── glossary/route.ts → GET all glossary entries
|
||||
├── components/
|
||||
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input)
|
||||
│ ├── page-header.tsx → Shared header with ThemeToggle
|
||||
│ ├── theme-provider.tsx → next-themes wrapper
|
||||
│ └── theme-toggle.tsx → Sun/moon toggle
|
||||
├── lib/
|
||||
│ ├── db.ts → Prisma client singleton
|
||||
│ ├── api.ts → Data access layer (getAllBooks, getBookMeta, getChapterVerses, etc.)
|
||||
│ ├── utils.ts → cn() helper
|
||||
│ └── jsons/books/ → Extracted JSON data
|
||||
│ ├── bg/ → BG chapters/verses/*.json + intro.json
|
||||
│ ├── sb/ → SB cantos/chapters/verses/*.json + intro.json
|
||||
│ ├── cc/ → CC lilas/chapters/verses/*.json + intro.json
|
||||
│ ├── sb-1962/ → 1962 SB chapters/verses/*.json + intro.json
|
||||
│ ├── kb/ → Kṛṣṇa Book
|
||||
│ │ ├── intro.json → Book metadata + chapter listing (89 chapters)
|
||||
│ │ ├── vol-1/intro/ → 5 intro segments (flap, dedication, George Harrison, preface, intro)
|
||||
│ │ ├── vol-1/chapters/ → 54 chapter JSONs
|
||||
│ │ └── vol-2/chapters/ → 35 chapter JSONs
|
||||
│ └── glossary/index.json → 2,749 glossary entries
|
||||
├── prisma/
|
||||
│ └── schema.prisma → Verse, BookMeta, ProseChapter, GlossaryEntry models
|
||||
├── scripts/
|
||||
│ ├── bg_extract.py → BG extraction from bg.html
|
||||
│ ├── sb_extract.py → SB extraction from sb.html
|
||||
│ ├── cc_extract.py → CC extraction from cc.html
|
||||
│ ├── sb1962_extract.py → 1962 SB extraction from others-before-1977.html
|
||||
│ ├── match_sb1962.py → Match 1962 SB transliterations with standard SB IAST
|
||||
│ ├── glossary_extract.py → Glossary extraction from glossary.html
|
||||
│ ├── kb_extract_intro.py → KB book-level intro + chapter listing
|
||||
│ ├── kb_extract_vol1_intro.py → KB Vol 1 front matter segments
|
||||
│ ├── kb_extract_vol1_chapters.py → KB Vol 1 chapters
|
||||
│ ├── kb_extract_vol2_chapters.py → KB Vol 2 chapters
|
||||
│ ├── fix-bengali-encoding.py → Fix Bengali font encoding in cc.html
|
||||
│ ├── inject-fonts.py → Inject @font-face rules into converted HTML
|
||||
│ ├── seed-db.ts → Main MongoDB seeder (all books + glossary)
|
||||
│ └── seed-kb-vol2.ts → KB Vol 2 seeder (uses $push workaround)
|
||||
└── public/
|
||||
├── rtfs/ → Original RTF files (~430MB, 17 files)
|
||||
├── html/ → HTML converted from RTF (textutil + @font-face injection)
|
||||
└── fonts/ → Custom TTF fonts (Balaram, ScaGoudy, Indevr, Inbenr, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (Prisma + MongoDB)
|
||||
|
||||
```prisma
|
||||
type Synonym {
|
||||
word String
|
||||
meanings String[]
|
||||
}
|
||||
|
||||
model Verse {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String // "bg" | "sb" | "cc" | "sb1962"
|
||||
division String? // null for BG/SB1962; canto for SB; lila for CC
|
||||
chapter Int
|
||||
verse Int
|
||||
verseEnd Int?
|
||||
reference String
|
||||
bookTitle String
|
||||
devanagari String
|
||||
bengali String? // only CC has Bengali
|
||||
transliteration String
|
||||
synonyms Synonym[]
|
||||
translation String
|
||||
purport String[]
|
||||
|
||||
@@unique([book, division, chapter, verse])
|
||||
@@index([book])
|
||||
@@index([book, division])
|
||||
@@index([book, division, chapter])
|
||||
@@map("verses")
|
||||
}
|
||||
|
||||
model BookMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique // "bg" | "sb" | "cc" | "sb1962" | "kb"
|
||||
data Json
|
||||
|
||||
@@map("book_meta")
|
||||
}
|
||||
|
||||
model ProseChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String // "kb"
|
||||
volume Int
|
||||
chapter Int? // null for intro segments
|
||||
title String
|
||||
type String // "chapter" | "intro-segment"
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
|
||||
@@unique([book, volume, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@index([book, volume, type])
|
||||
@@map("prose_chapters")
|
||||
}
|
||||
|
||||
model GlossaryEntry {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
term String
|
||||
definition String
|
||||
definitionHtml String
|
||||
|
||||
@@unique([term])
|
||||
@@map("glossary")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Books Extracted
|
||||
|
||||
| Book | Code | Type | Verses/Chapters | Division |
|
||||
|------|------|------|-----------------|----------|
|
||||
| Bhagavad-gītā As It Is | `bg` | verse | 653 verses, 18 chapters | — |
|
||||
| Śrīmad-Bhāgavatam | `sb` | verse | 8,260 verses, 213 chapters | 10 cantos |
|
||||
| Śrī Caitanya-caritāmṛta | `cc` | verse | 11,348 verses, 62 chapters | 3 līlās (adi/madhya/antya) |
|
||||
| SB First Canto (1962 Edition) | `sb1962` | verse | 762 verses, 19 chapters | — |
|
||||
| Kṛṣṇa Book | `kb` | prose | 89 chapters (54+35), 2 volumes | 2 volumes |
|
||||
| Glossary | — | glossary | 2,749 entries | — |
|
||||
|
||||
---
|
||||
|
||||
## Font Encoding System
|
||||
|
||||
### ScaGoudy → IAST mapping (used in BG, SB, CC, glossary)
|
||||
|
||||
```
|
||||
ä→ā Ä→Ā å→ṛ Å→Ṛ ñ→ṣ Ñ→Ṣ ö→ṭ Ö→Ṭ ë→ṇ Ë→Ṇ ò→ḍ Ò→Ḍ
|
||||
ç→ś Ç→Ś ì→ṅ ï→ñ ü→ū Ü→Ū é→ī É→Ī à→ṁ ù→ḥ è→ṝ û→ṁl ÿ→ḷ
|
||||
```
|
||||
|
||||
### Bengali fonts (Inbenr/Inbeni in CC)
|
||||
|
||||
Custom-encoded fonts where ASCII codepoints render as Bengali script. The cc.html had **Apple Symbol encoding corruption** — `textutil` mapped bytes through Symbol encoding instead of PUA. Fixed by `fix-bengali-encoding.py` which remaps 356,396 characters back to PUA range U+F0xx.
|
||||
|
||||
### Devanagari generation
|
||||
|
||||
IAST transliteration → Devanagari via `indic-transliteration` library. Key fixes:
|
||||
- `join_virama_words()` — joins virāma + consonant AND virāma + vowel (fixed 12,000+ rendering issues in SB)
|
||||
- `normalize_devanagari_anusvara()` — ङ् → ं before velars, ञ् → ं before palatals
|
||||
|
||||
---
|
||||
|
||||
## Verse Navigation (verse-based books)
|
||||
|
||||
- **One verse per page** with path-based URLs: `/books/bg/1/5`, `/books/sb/6/6/1`, `/books/cc/adi/1/3`
|
||||
- **ChapterViewer** (`app/books/chapter-viewer.tsx`): Client component with:
|
||||
- Sidebar showing ALL verse numbers (from lightweight index query)
|
||||
- Prev/Next buttons at top and bottom
|
||||
- Keyboard navigation (← →)
|
||||
- Client-side verse caching + prefetching adjacent verses
|
||||
- Click any verse in sidebar → fetches from API and navigates
|
||||
- **API route** supports: `?verse=N` (single), `?indexOnly=true` (lightweight index), `?page=&limit=` (paginated)
|
||||
- Server component SSRs the first verse, client handles navigation
|
||||
|
||||
---
|
||||
|
||||
## KB (Kṛṣṇa Book) — Prose Book
|
||||
|
||||
Different from verse books — chapters are prose paragraphs, no verse structure:
|
||||
- `ProseChapter` model with `paragraphs: String[]`
|
||||
- 5 intro segments per volume (flap, dedication, George Harrison intro, preface, introduction)
|
||||
- Sanskrit verses within prose are grouped into `<div style="text-align:center"><i>...</i></div>` blocks with `<br>` tags
|
||||
- Reading page has left sidebar with chapter index, prev/next navigation
|
||||
|
||||
---
|
||||
|
||||
## Data Access Layer (`lib/api.ts`)
|
||||
|
||||
```typescript
|
||||
// Book meta
|
||||
getAllBooks(): Promise<BookMeta[]>
|
||||
getBookMeta(book: string): Promise<BookMeta | null>
|
||||
|
||||
// Verse index (lightweight — for sidebar)
|
||||
getChapterVerseIndex(book, division, chapter): Promise<VerseIndexEntry[]>
|
||||
|
||||
// Single verse
|
||||
getSingleVerse(book, division, chapter, verse): Promise<VerseRecord | null>
|
||||
|
||||
// Paginated verses
|
||||
getChapterVerses(book, division, chapter, page?, limit?): Promise<PaginatedVerses>
|
||||
|
||||
// Prose chapters (for KB)
|
||||
getProseChapters(book, volume, type?): Promise<ProseChapterRecord[]>
|
||||
getProseChapter(book, volume, slug): Promise<ProseChapterRecord | null>
|
||||
|
||||
// Glossary
|
||||
getAllGlossaryEntries(): Promise<GlossaryRecord[]>
|
||||
```
|
||||
|
||||
The data access layer includes `normalizeProse()` which handles mixed storage (some chapters stored with native arrays, some with stringified JSON due to BSON corruption workaround). It also includes `sanitizeHtml()` which strips `<script>`, `<style>`, `<iframe>`, event handlers from paragraphs.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **MongoDB BSON wire corruption**: The MongoDB server (7.0.31) behind a proxy/tunnel occasionally corrupts large BSON documents during transmission. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time. The `insertMany` and large `insertOne` operations fail non-deterministically with "Invalid UTF-8 string" or "Unrecognized BSON type" errors. Retrying usually works.
|
||||
|
||||
2. **SB verse numbering**: The HTML had 5 duplicate `p20` verse reference tags. Fixed by using the `p21` TEXT N header as the authoritative verse number (corrects the wrong `p20` reference).
|
||||
|
||||
3. **SB 1962 chapter 15**: Verses 41-45 have different numbering between the 1962 edition and standard SB. The matching script skips these 5 verses (keeps original plain English transliteration).
|
||||
|
||||
4. **RTF routes gated**: `/rtfs` and `/rtfs/[slug]` only accessible when `ALLOW_RTF=true` env variable is set.
|
||||
|
||||
5. **`scripts/` excluded from tsconfig**: Added to `"exclude"` array to prevent TypeScript build errors from seed scripts.
|
||||
|
||||
6. **MongoDB requires no replica set for reads** but requires replica set for Prisma writes. All writes use the native MongoDB driver instead of Prisma.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
|
||||
ALLOW_RTF=true # optional — enables RTF viewer routes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
|
||||
# Prisma
|
||||
npx prisma generate # Regenerate Prisma client after schema changes
|
||||
|
||||
# Extraction scripts (Python 3 + beautifulsoup4 + indic-transliteration)
|
||||
python3 scripts/bg_extract.py # Extract BG → lib/jsons/books/bg/
|
||||
python3 scripts/sb_extract.py # Extract SB → lib/jsons/books/sb/
|
||||
python3 scripts/cc_extract.py # Extract CC → lib/jsons/books/cc/
|
||||
python3 scripts/glossary_extract.py # Extract glossary → lib/jsons/glossary/
|
||||
python3 scripts/sb1962_extract.py # Extract 1962 SB → lib/jsons/books/sb-1962/
|
||||
python3 scripts/match_sb1962.py # Match 1962 SB with standard SB IAST
|
||||
python3 scripts/kb_extract_intro.py # Extract KB intro → lib/jsons/books/kb/intro.json
|
||||
python3 scripts/kb_extract_vol1_intro.py # KB Vol 1 front matter
|
||||
python3 scripts/kb_extract_vol1_chapters.py # KB Vol 1 chapters
|
||||
python3 scripts/kb_extract_vol2_chapters.py # KB Vol 2 chapters
|
||||
|
||||
# Database seeding (TypeScript + tsx)
|
||||
npx tsx scripts/seed-db.ts # Seed all data to MongoDB
|
||||
npx tsx scripts/seed-kb-vol2.ts # Seed KB Vol 2 (separate due to BSON issues)
|
||||
|
||||
# Build
|
||||
npx next build # Production build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URL Routes
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/` | Landing page — Books, Glossary, RTF Viewer cards |
|
||||
| `/books` | All books index |
|
||||
| `/books/bg` | BG chapter list |
|
||||
| `/books/bg/[chapter]` | Redirects to first verse |
|
||||
| `/books/bg/[chapter]/[verse]` | Single verse viewer |
|
||||
| `/books/sb` | SB canto list |
|
||||
| `/books/sb/[canto]` | Chapter list for a canto |
|
||||
| `/books/sb/[canto]/[chapter]` | Redirects to first verse |
|
||||
| `/books/sb/[canto]/[chapter]/[verse]` | Single verse viewer |
|
||||
| `/books/cc` | CC līlā list |
|
||||
| `/books/cc/[lila]` | Chapter list for a līlā |
|
||||
| `/books/cc/[lila]/[chapter]` | Redirects to first verse |
|
||||
| `/books/cc/[lila]/[chapter]/[verse]` | Single verse viewer |
|
||||
| `/books/sb1962` | 1962 SB chapter list |
|
||||
| `/books/sb1962/[chapter]` | Redirects to first verse |
|
||||
| `/books/sb1962/[chapter]/[verse]` | Single verse viewer |
|
||||
| `/books/kb` | KB volume list |
|
||||
| `/books/kb/[volume]` | Chapter list + intro segments |
|
||||
| `/books/kb/[volume]/[slug]` | Prose reading page with sidebar |
|
||||
| `/glossary` | Searchable glossary (2,749 entries) |
|
||||
| `/rtfs` | RTF file listing (requires ALLOW_RTF=true) |
|
||||
| `/rtfs/[slug]` | RTF iframe viewer (requires ALLOW_RTF=true) |
|
||||
| `/api/books` | GET all book metas |
|
||||
| `/api/books/[book]` | GET single book meta |
|
||||
| `/api/books/[book]/verses` | GET verses with query params |
|
||||
| `/api/glossary` | GET all glossary entries |
|
||||
|
||||
---
|
||||
|
||||
## Verse JSON Schema (BG/SB/CC/SB1962)
|
||||
|
||||
```json
|
||||
{
|
||||
"book_title": "Śrīmad-Bhāgavatam",
|
||||
"canto": 1,
|
||||
"chapter": 1,
|
||||
"verse": 1,
|
||||
"verse_end": null,
|
||||
"reference": "SB 1.1.1",
|
||||
"devanagari": "ॐ नमो भगवते वासुदेवाय\nजन्माद्यस्य यतो ...",
|
||||
"bengali": null,
|
||||
"transliteration": "om namo bhagavate vāsudevāya\njanmādy asya yato ...",
|
||||
"synonyms": [
|
||||
{ "word": "om", "meanings": ["O my Lord"] },
|
||||
{ "word": "namaḥ", "meanings": ["offering my obeisances"] }
|
||||
],
|
||||
"translation": "<b>I offer my obeisances unto Lord Śrī Kṛṣṇa...</b>",
|
||||
"purport": ["Obeisances unto the Personality of Godhead...", "paragraph 2..."]
|
||||
}
|
||||
```
|
||||
|
||||
## Prose Chapter JSON Schema (KB)
|
||||
|
||||
```json
|
||||
{
|
||||
"book": "kb",
|
||||
"volume": 1,
|
||||
"chapter": 1,
|
||||
"title": "Advent of Lord Kṛṣṇa",
|
||||
"type": "chapter",
|
||||
"paragraphs": [
|
||||
"<b>1 /Advent of Lord Kṛṣṇa</b>",
|
||||
"Once the world was overburdened...",
|
||||
"<div style=\"text-align:center\"><i>verse line 1<br>verse line 2</i></div>",
|
||||
"<i>Thus ends the Bhaktivedanta purport of the First Chapter of </i>Kṛṣṇa..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Intro Segment JSON Schema (KB)
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "words-from-apple",
|
||||
"title": "Words from Apple — by George Harrison",
|
||||
"type": "intro-segment",
|
||||
"book": "kb",
|
||||
"volume": 1,
|
||||
"order": 3,
|
||||
"paragraphs": ["<b>Words From Apple</b>", "Everybody is looking for KṚṢṆA.", "..."]
|
||||
}
|
||||
```
|
||||
|
||||
## Glossary JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Glossary of Vedic Terms",
|
||||
"description": "A comprehensive glossary of Sanskrit and Vedic terms...",
|
||||
"totalEntries": 2749,
|
||||
"letters": ["A", "B", "C", ...],
|
||||
"entries": [
|
||||
{
|
||||
"term": "Ācārya",
|
||||
"definition": "a spiritual master who teaches by his own example...",
|
||||
"definition_html": "a spiritual master who teaches by his own example..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBbdContent,
|
||||
getBbdSidebarItems,
|
||||
type BbdChapter,
|
||||
type BbdSidebarItem,
|
||||
} from "@/lib/bbd";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function BbdChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getBbdContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getBbdSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as BbdChapter).chapter === "number" &&
|
||||
(content as BbdChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as BbdChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/bbd"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Beyond Birth and Death
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<BbdSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<BbdNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<BbdNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BbdSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: BbdSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: BbdSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/bbd/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BbdNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: BbdSidebarItem | null;
|
||||
next: BbdSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/bbd/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/bbd/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBbdMeta, getBbdIntroSegments } from "@/lib/bbd";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function BbdPage() {
|
||||
const meta = await getBbdMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getBbdIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/bbd/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/bbd/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Search } from "lucide-react";
|
||||
import { ExtractedVerseCount } from "./extracted-verse-count";
|
||||
|
||||
export type BookInfo = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
contentDescription: string;
|
||||
totalVerses: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
|
||||
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
|
||||
*
|
||||
* Uses Unicode NFD decomposition to split base characters from
|
||||
* combining marks, then removes the marks. Also handles special
|
||||
* IAST characters that don't decompose cleanly via NFD.
|
||||
*/
|
||||
function normalize(text: string): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
// Handle IAST characters that NFD doesn't decompose into ASCII base
|
||||
.replace(/ṛ/g, "r")
|
||||
.replace(/ṝ/g, "r")
|
||||
.replace(/ṣ/g, "s")
|
||||
.replace(/ś/g, "s")
|
||||
.replace(/ṭ/g, "t")
|
||||
.replace(/ḍ/g, "d")
|
||||
.replace(/ṇ/g, "n")
|
||||
.replace(/ñ/g, "n")
|
||||
.replace(/ṅ/g, "n")
|
||||
.replace(/ḥ/g, "h")
|
||||
.replace(/ṁ/g, "m")
|
||||
.replace(/ṃ/g, "m")
|
||||
.replace(/ḷ/g, "l")
|
||||
// NFD decompose + strip combining marks (handles ā→a, ī→i, ū→u, etc.)
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
export function BooksGrid({ books }: { books: BookInfo[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalizedBooks = useMemo(
|
||||
() =>
|
||||
books.map((book) => ({
|
||||
book,
|
||||
searchText: normalize(
|
||||
`${book.title} ${book.contentDescription} ${book.description} ${book.slug}`,
|
||||
),
|
||||
})),
|
||||
[books],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return books;
|
||||
const q = normalize(query.trim());
|
||||
return normalizedBooks
|
||||
.filter((entry) => entry.searchText.includes(q))
|
||||
.map((entry) => entry.book);
|
||||
}, [books, normalizedBooks, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search books..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results count when searching */}
|
||||
{query.trim() && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filtered.length === 0
|
||||
? "No books found"
|
||||
: `${filtered.length} book${filtered.length === 1 ? "" : "s"} found`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((book) => (
|
||||
<Link key={book.slug} href={book.href} className="block h-full">
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-bold leading-snug">
|
||||
{book.title}
|
||||
</CardTitle>
|
||||
{book.contentDescription && (
|
||||
<CardDescription className="text-xs line-clamp-2">
|
||||
{book.contentDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{book.description && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{book.description}
|
||||
</Badge>
|
||||
)}
|
||||
{book.totalVerses > 0 && (
|
||||
<ExtractedVerseCount
|
||||
count={book.totalVerses}
|
||||
label="verses"
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,7 @@ function VerseCard({
|
||||
|
||||
{/* Devanagari */}
|
||||
<div className="text-center">
|
||||
<p className="text-xl leading-relaxed text-red-800 dark:text-red-400 whitespace-pre-line font-serif">
|
||||
<p className="text-xl leading-relaxed text-red-800 dark:text-white whitespace-pre-line font-serif">
|
||||
{verse.devanagari}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getDsContent,
|
||||
getDsSidebarItems,
|
||||
type DsChapter,
|
||||
type DsSidebarItem,
|
||||
} from "@/lib/ds";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function DsChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getDsContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getDsSidebarItems();
|
||||
|
||||
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
|
||||
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
|
||||
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < navigableItems.length - 1
|
||||
? navigableItems[currentIdx + 1]
|
||||
: null;
|
||||
|
||||
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
|
||||
const isSubChapter =
|
||||
"parentChapter" in content &&
|
||||
typeof (content as DsChapter).parentChapter === "number";
|
||||
const parentChapterNum = isSubChapter
|
||||
? (content as DsChapter).parentChapter
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/ds"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Dialectic Spiritualism
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{parentChapterNum != null && (
|
||||
<span className="text-primary">Chapter {parentChapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<DsSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<DsNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<DsNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DsSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: DsSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items by section
|
||||
const introItems = items.filter((i) => i.type === "intro-segment");
|
||||
const parents = items.filter((i) => i.type === "parent");
|
||||
const subChapters = items.filter((i) => i.type === "sub-chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters — grouped by parent */}
|
||||
{parents.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{parents.map((parent) => (
|
||||
<div key={parent.slug}>
|
||||
{/* Parent heading — not a link, just a bold label */}
|
||||
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
|
||||
{parent.label}
|
||||
</div>
|
||||
{/* Sub-chapters indented */}
|
||||
{subChapters
|
||||
.filter((sub) => sub.parentChapter === parent.parentChapter)
|
||||
.map((sub) => {
|
||||
const isActive = sub.slug === currentSlug;
|
||||
return (
|
||||
<Link
|
||||
key={sub.slug}
|
||||
href={`/books/ds/${sub.slug}`}
|
||||
className={cn(
|
||||
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={sub.label}
|
||||
>
|
||||
{sub.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: DsSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ds/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DsNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: DsSidebarItem | null;
|
||||
next: DsSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ds/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ds/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDsMeta, getDsIntroSegments } from "@/lib/ds";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function DsPage() {
|
||||
const meta = await getDsMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getDsIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
{meta.subtitle && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{meta.subtitle}</p>
|
||||
)}
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ds/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters — hierarchical parent → sub-chapter structure */}
|
||||
{meta.chapters.map((ch) => (
|
||||
<div key={ch.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{ch.number}. {ch.title}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{ch.subChapters.map((sub) => (
|
||||
<li key={sub.slug}>
|
||||
<Link
|
||||
href={`/books/ds/${sub.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{sub.title}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getEjContent,
|
||||
getEjSidebarItems,
|
||||
type EjChapter,
|
||||
type EjSidebarItem,
|
||||
} from "@/lib/ej";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function EjChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getEjContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getEjSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as EjChapter).chapter === "number" &&
|
||||
(content as EjChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as EjChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/ej"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Easy Journey to Other Planets
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<EjSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<EjNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<EjNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EjSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: EjSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: EjSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ej/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EjNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: EjSidebarItem | null;
|
||||
next: EjSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ej/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ej/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getEjMeta, getEjIntroSegments } from "@/lib/ej";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function EjPage() {
|
||||
const meta = await getEjMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getEjIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground italic">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ej/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ej/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getEkContent,
|
||||
getEkSidebarItems,
|
||||
type EkChapter,
|
||||
type EkSidebarItem,
|
||||
} from "@/lib/ek";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function EkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getEkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getEkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as EkChapter).chapter === "number" &&
|
||||
(content as EkChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as EkChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/ek"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Elevation to Kṛṣṇa Consciousness
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<EkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<EkNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<EkNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: EkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: EkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ek/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EkNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: EkSidebarItem | null;
|
||||
next: EkSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ek/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ek/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getEkMeta, getEkIntroSegments } from "@/lib/ek";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function EkPage() {
|
||||
const meta = await getEkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getEkIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ek/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ek/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getHkhContent,
|
||||
getHkhSidebarItems,
|
||||
type HkhChapter,
|
||||
type HkhSidebarItem,
|
||||
} from "@/lib/hkh";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function HkhChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getHkhContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getHkhSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as HkhChapter).chapter === "number" &&
|
||||
(content as HkhChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as HkhChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/hkh"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Kṛṣṇa Consciousness Handbook
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<HkhSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<HkhNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<HkhNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HkhSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: HkhSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: HkhSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/hkh/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HkhNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: HkhSidebarItem | null;
|
||||
next: HkhSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/hkh/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/hkh/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getHkhMeta, getHkhIntroSegments } from "@/lib/hkh";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function HkhPage() {
|
||||
const meta = await getHkhMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getHkhIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/hkh/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/hkh/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getIsoContent,
|
||||
getIsoSidebarItems,
|
||||
type IsoVerse,
|
||||
type IsoIntroSegment,
|
||||
type IsoSidebarItem,
|
||||
} from "@/lib/iso";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isVerse(content: IsoVerse | IsoIntroSegment): content is IsoVerse {
|
||||
return "mantra" in content && "devanagari" in content;
|
||||
}
|
||||
|
||||
function isIntroSegment(
|
||||
content: IsoVerse | IsoIntroSegment,
|
||||
): content is IsoIntroSegment {
|
||||
return "paragraphs" in content && "order" in content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function IsoMantraPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ mantra: string }>;
|
||||
}) {
|
||||
const { mantra: slug } = await params;
|
||||
|
||||
const content = await getIsoContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getIsoSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title for the header
|
||||
const verse = isVerse(content) ? content : null;
|
||||
const intro = isIntroSegment(content) ? content : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/iso"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Śrī Īśopaniṣad
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{verse && (
|
||||
<>
|
||||
<span className="text-primary">{verse.reference}: </span>
|
||||
{verse.title}
|
||||
</>
|
||||
)}
|
||||
{intro && intro.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<IsoSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<IsoNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
{verse && <VerseContent verse={verse} />}
|
||||
{intro && <IntroContent intro={intro} />}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<IsoNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerseContent({ verse }: { verse: IsoVerse }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
|
||||
{/* Devanagari */}
|
||||
<section>
|
||||
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
|
||||
{verse.devanagari}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transliteration */}
|
||||
<section>
|
||||
<div className="text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
|
||||
{verse.transliteration}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Synonyms */}
|
||||
{verse.synonyms.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Synonyms
|
||||
</h2>
|
||||
<div className="text-sm text-foreground/70 leading-relaxed">
|
||||
{verse.synonyms.map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && "; "}
|
||||
<span className="font-medium text-foreground/90">
|
||||
{s.word}
|
||||
</span>
|
||||
{"—"}
|
||||
{s.meanings.join(", ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Translation */}
|
||||
<section>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Translation
|
||||
</h2>
|
||||
<div
|
||||
className="text-foreground font-medium leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: verse.translation }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Purport */}
|
||||
{verse.purport.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Purport
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{verse.purport.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: p }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segment content (paragraphs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IntroContent({ intro }: { intro: IsoIntroSegment }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{intro.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IsoSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: IsoSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Front matter items (front-pages + introduction)
|
||||
const frontMatterItems = items.filter(
|
||||
(item) =>
|
||||
item.slug === "intro-front-pages" || item.slug === "intro-introduction",
|
||||
);
|
||||
// Mantras = invocation + 1-18
|
||||
const mantraItems = items.filter((item) => item.type === "verse");
|
||||
// about-the-author comes last
|
||||
const aboutAuthorItem = items.find(
|
||||
(item) => item.slug === "intro-about-the-author",
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mantras */}
|
||||
{mantraItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Mantras
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{mantraItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* About the Author */}
|
||||
{aboutAuthorItem && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
<SidebarLink
|
||||
item={aboutAuthorItem}
|
||||
isActive={aboutAuthorItem.slug === currentSlug}
|
||||
/>
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: IsoSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/iso/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IsoNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: IsoSidebarItem | null;
|
||||
next: IsoSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/iso/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "verse" && prev.mantra != null && prev.mantra > 0
|
||||
? `Mantra ${prev.mantra}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/iso/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "verse" && next.mantra != null && next.mantra > 0
|
||||
? `Mantra ${next.mantra}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsoMeta, getIsoIntroSegments } from "@/lib/iso";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function IsoPage() {
|
||||
const meta = await getIsoMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getIsoIntroSegments();
|
||||
|
||||
const frontPages = introSegments.find((s) => s.slug === "front-pages");
|
||||
const introduction = introSegments.find((s) => s.slug === "introduction");
|
||||
const aboutAuthor = introSegments.find((s) => s.slug === "about-the-author");
|
||||
|
||||
// Invocation is mantra 0, mantras are 1-18
|
||||
const invocation = meta.mantras.find((m) => m.number === 0);
|
||||
const numberedMantras = meta.mantras.filter((m) => m.number > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{(frontPages || introduction) && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontPages && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/intro-front-pages"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{frontPages.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{frontPages.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{introduction && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/intro-introduction"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{introduction.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mantras */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Mantras
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{/* Invocation */}
|
||||
{invocation && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/invocation"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Invocation
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{invocation.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* Mantras 1-18 */}
|
||||
{numberedMantras.map((m) => (
|
||||
<li key={m.number}>
|
||||
<Link
|
||||
href={`/books/iso/${m.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Mantra {m.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{m.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* About the Author */}
|
||||
{aboutAuthor && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
About the Author
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/intro-about-the-author"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{aboutAuthor.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{aboutAuthor.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getMgContent,
|
||||
getMgSidebarItems,
|
||||
type MgChapter,
|
||||
type MgSidebarItem,
|
||||
} from "@/lib/mg";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function MgChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getMgContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getMgSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as MgChapter).chapter === "number" &&
|
||||
(content as MgChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as MgChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/mg"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Kṛṣṇa Consciousness: The Matchless Gift
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<MgSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<MgNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<MgNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MgSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: MgSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: MgSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/mg/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MgNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: MgSidebarItem | null;
|
||||
next: MgSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/mg/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/mg/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getMgMeta, getMgIntroSegments } from "@/lib/mg";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function MgPage() {
|
||||
const meta = await getMgMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getMgIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/mg/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/mg/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getNodContent,
|
||||
getNodSidebarItems,
|
||||
type NodChapter,
|
||||
type NodSidebarItem,
|
||||
} from "@/lib/nod";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function NodChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getNodContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getNodSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as NodChapter).chapter === "number" &&
|
||||
(content as NodChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as NodChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/nod"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Nectar of Devotion
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<NodSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<NodNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<NodNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: NodSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
const conclusionItems = items.filter((item) => item.type === "conclusion");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Concluding Words */}
|
||||
{conclusionItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{conclusionItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: NodSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/nod/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: NodSidebarItem | null;
|
||||
next: NodSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/nod/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/nod/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getNodMeta, getNodIntroSegments } from "@/lib/nod";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const PART_LABELS: Record<number, string> = {
|
||||
1: "Part One",
|
||||
2: "Part Two",
|
||||
3: "Part Three",
|
||||
4: "Part Four",
|
||||
};
|
||||
|
||||
export default async function NodPage() {
|
||||
const meta = await getNodMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getNodIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground italic">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/nod/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts & Chapters */}
|
||||
{meta.parts.map((part) => {
|
||||
const partChapters = part.chapters
|
||||
.map((num) => meta.chapters.find((ch) => ch.number === num))
|
||||
.filter(Boolean) as { number: number; title: string }[];
|
||||
|
||||
return (
|
||||
<div key={part.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{PART_LABELS[part.number] ?? `Part ${part.number}`}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{partChapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/nod/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Concluding Words */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Conclusion
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/nod/concluding-words"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Concluding Words
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getNoiContent,
|
||||
getNoiSidebarItems,
|
||||
type NoiVerse,
|
||||
type NoiIntroSegment,
|
||||
type NoiSidebarItem,
|
||||
} from "@/lib/noi";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isVerse(content: NoiVerse | NoiIntroSegment): content is NoiVerse {
|
||||
return "text" in content && "devanagari" in content;
|
||||
}
|
||||
|
||||
function isIntroSegment(
|
||||
content: NoiVerse | NoiIntroSegment,
|
||||
): content is NoiIntroSegment {
|
||||
return "paragraphs" in content && "order" in content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function NoiTextPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ text: string }>;
|
||||
}) {
|
||||
const { text: slug } = await params;
|
||||
|
||||
const content = await getNoiContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getNoiSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title for the header
|
||||
const verse = isVerse(content) ? content : null;
|
||||
const intro = isIntroSegment(content) ? content : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/noi"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Nectar of Instruction
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{verse && (
|
||||
<>
|
||||
<span className="text-primary">Text {verse.text}: </span>
|
||||
{verse.title}
|
||||
</>
|
||||
)}
|
||||
{intro && intro.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<NoiSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<NoiNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
{verse && <VerseContent verse={verse} />}
|
||||
{intro && <IntroContent intro={intro} />}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<NoiNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerseContent({ verse }: { verse: NoiVerse }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
|
||||
{/* Devanagari */}
|
||||
<section>
|
||||
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
|
||||
{verse.devanagari}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transliteration */}
|
||||
<section>
|
||||
<div className="text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
|
||||
{verse.transliteration}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Synonyms */}
|
||||
{verse.synonyms.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Synonyms
|
||||
</h2>
|
||||
<div className="text-sm text-foreground/70 leading-relaxed">
|
||||
{verse.synonyms.map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && "; "}
|
||||
<span className="font-medium text-foreground/90">
|
||||
{s.word}
|
||||
</span>
|
||||
{"—"}
|
||||
{s.meanings.join(", ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Translation */}
|
||||
<section>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Translation
|
||||
</h2>
|
||||
<div
|
||||
className="text-foreground font-medium leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: verse.translation }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Purport */}
|
||||
{verse.purport.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Purport
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{verse.purport.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: p }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segment content (paragraphs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IntroContent({ intro }: { intro: NoiIntroSegment }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{intro.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoiSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: NoiSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Front matter items (front-pages + preface)
|
||||
const frontMatterItems = items.filter(
|
||||
(item) =>
|
||||
item.slug === "intro-front-pages" || item.slug === "intro-preface",
|
||||
);
|
||||
// Texts = 1-11
|
||||
const textItems = items.filter((item) => item.type === "verse");
|
||||
// Appendixes comes last
|
||||
const appendixesItem = items.find(
|
||||
(item) => item.slug === "intro-appendixes",
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Texts */}
|
||||
{textItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Texts
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{textItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Appendixes */}
|
||||
{appendixesItem && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
<SidebarLink
|
||||
item={appendixesItem}
|
||||
isActive={appendixesItem.slug === currentSlug}
|
||||
/>
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: NoiSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/noi/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoiNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: NoiSidebarItem | null;
|
||||
next: NoiSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/noi/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "verse" && prev.text != null
|
||||
? `Text ${prev.text}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/noi/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "verse" && next.text != null
|
||||
? `Text ${next.text}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getNoiMeta, getNoiIntroSegments } from "@/lib/noi";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function NoiPage() {
|
||||
const meta = await getNoiMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getNoiIntroSegments();
|
||||
|
||||
const frontPages = introSegments.find((s) => s.slug === "front-pages");
|
||||
const preface = introSegments.find((s) => s.slug === "preface");
|
||||
const appendixes = introSegments.find((s) => s.slug === "appendixes");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 italic text-muted-foreground">{meta.subtitle}</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{(frontPages || preface) && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontPages && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/noi/intro-front-pages"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{frontPages.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{frontPages.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{preface && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/noi/intro-preface"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{preface.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{preface.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Texts */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Texts
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.texts.map((t) => (
|
||||
<li key={t.number}>
|
||||
<Link
|
||||
href={`/books/noi/${t.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Text {t.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{t.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Appendixes */}
|
||||
{appendixes && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Appendixes
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/noi/intro-appendixes"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{appendixes.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{appendixes.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getOchkmContent,
|
||||
getOchkmSidebarItems,
|
||||
type OchkmChapter,
|
||||
type OchkmSidebarItem,
|
||||
} from "@/lib/ochkm";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function OchkmChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getOchkmContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getOchkmSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as OchkmChapter).chapter === "number" &&
|
||||
(content as OchkmChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as OchkmChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/ochkm"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← On Chanting the Hare Kṛṣṇa Mantra
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<OchkmSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<OchkmNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<OchkmNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OchkmSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: OchkmSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: OchkmSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ochkm/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OchkmNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: OchkmSidebarItem | null;
|
||||
next: OchkmSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ochkm/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ochkm/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getOchkmMeta, getOchkmIntroSegments } from "@/lib/ochkm";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function OchkmPage() {
|
||||
const meta = await getOchkmMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getOchkmIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ochkm/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ochkm/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getOwkContent,
|
||||
getOwkSidebarItems,
|
||||
type OwkChapter,
|
||||
type OwkSidebarItem,
|
||||
} from "@/lib/owk";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function OwkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getOwkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getOwkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as OwkChapter).chapter === "number" &&
|
||||
(content as OwkChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as OwkChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/owk"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← On the Way to Kṛṣṇa
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<OwkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<OwkNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<OwkNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OwkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: OwkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: OwkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/owk/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OwkNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: OwkSidebarItem | null;
|
||||
next: OwkSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/owk/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/owk/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getOwkMeta, getOwkIntroSegments } from "@/lib/owk";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function OwkPage() {
|
||||
const meta = await getOwkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getOwkIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/owk/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/owk/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-62
@@ -1,16 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExtractedVerseCount } from "./extracted-verse-count";
|
||||
import { getAllBooks, type BookMeta } from "@/lib/api";
|
||||
import { BooksGrid, type BookInfo } from "./books-grid";
|
||||
|
||||
type ChapterEntry = { totalVerses: number };
|
||||
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
||||
@@ -21,12 +12,6 @@ interface ChapterBookMeta extends BookMeta {
|
||||
chapters: ChapterEntry[];
|
||||
}
|
||||
|
||||
interface KbMeta extends BookMeta {
|
||||
totalChapters: number;
|
||||
description: string;
|
||||
volumes: { number: number; totalChapters: number }[];
|
||||
}
|
||||
|
||||
interface SbMeta extends BookMeta {
|
||||
totalCantos: number;
|
||||
cantos: CantoEntry[];
|
||||
@@ -37,15 +22,6 @@ interface CcMeta extends BookMeta {
|
||||
lilas: LilaEntry[];
|
||||
}
|
||||
|
||||
type BookInfo = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
totalVerses: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||
const books: BookInfo[] = [];
|
||||
|
||||
@@ -65,6 +41,8 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||
title,
|
||||
author,
|
||||
description: `${bg.totalChapters} chapters`,
|
||||
contentDescription:
|
||||
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
|
||||
totalVerses,
|
||||
href: "/books/bg",
|
||||
});
|
||||
@@ -79,6 +57,9 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||
title,
|
||||
author,
|
||||
description: `${sb62.totalChapters} chapters · First Canto`,
|
||||
contentDescription:
|
||||
(data as { description?: string }).description ||
|
||||
"The original 1962 Indian edition of the First Canto.",
|
||||
totalVerses,
|
||||
href: "/books/sb1962",
|
||||
});
|
||||
@@ -97,6 +78,8 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||
title,
|
||||
author,
|
||||
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
|
||||
contentDescription:
|
||||
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
|
||||
totalVerses,
|
||||
href: "/books/sb",
|
||||
});
|
||||
@@ -115,18 +98,37 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||
title,
|
||||
author,
|
||||
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
|
||||
contentDescription:
|
||||
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
|
||||
totalVerses,
|
||||
href: "/books/cc",
|
||||
});
|
||||
} else if (slug === "kb") {
|
||||
const kb = data as KbMeta;
|
||||
} else {
|
||||
// Generic handler for all other books — pulls description from metadata
|
||||
const metaDesc = (data as { description?: string }).description || "";
|
||||
const totalChapters = (data as { totalChapters?: number }).totalChapters;
|
||||
const totalMantras = (data as { totalMantras?: number }).totalMantras;
|
||||
const totalTexts = (data as { totalTexts?: number }).totalTexts;
|
||||
const parts = (data as { parts?: unknown[] }).parts;
|
||||
const volumes = (data as { volumes?: unknown[] }).volumes;
|
||||
|
||||
let badge = "";
|
||||
if (totalMantras) badge = `${totalMantras} mantras`;
|
||||
else if (totalTexts) badge = `${totalTexts} texts`;
|
||||
else if (totalChapters && volumes)
|
||||
badge = `${totalChapters} chapters · ${volumes.length} volumes`;
|
||||
else if (totalChapters && parts)
|
||||
badge = `${totalChapters} chapters · ${parts.length} parts`;
|
||||
else if (totalChapters) badge = `${totalChapters} chapters`;
|
||||
|
||||
books.push({
|
||||
slug,
|
||||
title,
|
||||
author,
|
||||
description: `${kb.totalChapters} chapters · 2 volumes`,
|
||||
description: badge,
|
||||
contentDescription: metaDesc,
|
||||
totalVerses: 0,
|
||||
href: "/books/kb",
|
||||
href: `/books/${slug}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -149,42 +151,12 @@ export default async function BooksPage() {
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Structured verse-by-verse editions with Devanagari, transliteration,
|
||||
synonyms, translation & purports
|
||||
Browse the books and teachings of Śrīla Prabhupāda
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="grid gap-4">
|
||||
{books.map((book) => (
|
||||
<Link key={book.slug} href={book.href} className="block">
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-all hover:shadow-md hover:ring-foreground/20",
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{book.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{book.author}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Badge variant="secondary">{book.description}</Badge>
|
||||
{book.totalVerses > 0 && (
|
||||
<ExtractedVerseCount
|
||||
count={book.totalVerses}
|
||||
label="extracted verses"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<BooksGrid books={books} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getPoyContent,
|
||||
getPoySidebarItems,
|
||||
type PoyChapter,
|
||||
type PoySidebarItem,
|
||||
} from "@/lib/poy";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function PoyChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getPoyContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getPoySidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as PoyChapter).chapter === "number" &&
|
||||
(content as PoyChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as PoyChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/poy"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Perfection of Yoga
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<PoySidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<PoyNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<PoyNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PoySidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: PoySidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: PoySidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/poy/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PoyNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: PoySidebarItem | null;
|
||||
next: PoySidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/poy/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/poy/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPoyMeta, getPoyIntroSegments } from "@/lib/poy";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function PoyPage() {
|
||||
const meta = await getPoyMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getPoyIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/poy/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/poy/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getPqpaContent,
|
||||
getPqpaSidebarItems,
|
||||
type PqpaChapter,
|
||||
type PqpaSidebarItem,
|
||||
} from "@/lib/pqpa";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function PqpaChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getPqpaContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getPqpaSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as PqpaChapter).chapter === "number" &&
|
||||
(content as PqpaChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as PqpaChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/pqpa"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Perfect Questions Perfect Answers
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<PqpaSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<PqpaNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<PqpaNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PqpaSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: PqpaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Chapters: chapter items
|
||||
// Conclusion: conclusion items
|
||||
// Back matter: intro-segment items after conclusion (about-the-author)
|
||||
|
||||
const frontMatterItems: PqpaSidebarItem[] = [];
|
||||
const chapterItems: PqpaSidebarItem[] = [];
|
||||
const conclusionItems: PqpaSidebarItem[] = [];
|
||||
const backMatterItems: PqpaSidebarItem[] = [];
|
||||
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (item.type === "conclusion") {
|
||||
pastChapters = true;
|
||||
conclusionItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Concluding Words */}
|
||||
{conclusionItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{conclusionItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* About the Author */}
|
||||
{backMatterItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: PqpaSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/pqpa/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PqpaNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: PqpaSidebarItem | null;
|
||||
next: PqpaSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/pqpa/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/pqpa/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPqpaMeta, getPqpaIntroSegments } from "@/lib/pqpa";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function PqpaPage() {
|
||||
const meta = await getPqpaMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getPqpaIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/pqpa/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/pqpa/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Concluding Words */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Conclusion
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/pqpa/concluding-words"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Concluding Words
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* About the Author */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/pqpa/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getRvContent,
|
||||
getRvSidebarItems,
|
||||
type RvChapter,
|
||||
type RvSidebarItem,
|
||||
} from "@/lib/rv";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function RvChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getRvContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getRvSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as RvChapter).chapter === "number" &&
|
||||
(content as RvChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as RvChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/rv"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Rāja-vidyā: The King of Knowledge
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<RvSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<RvNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<RvNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RvSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: RvSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: RvSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/rv/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RvNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: RvSidebarItem | null;
|
||||
next: RvSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/rv/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/rv/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRvMeta, getRvIntroSegments } from "@/lib/rv";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function RvPage() {
|
||||
const meta = await getRvMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getRvIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/rv/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/rv/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getSsrContent,
|
||||
getSsrSidebarItems,
|
||||
type SsrChapter,
|
||||
type SsrSidebarItem,
|
||||
} from "@/lib/ssr";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function SsrChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getSsrContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getSsrSidebarItems();
|
||||
|
||||
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
|
||||
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
|
||||
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < navigableItems.length - 1
|
||||
? navigableItems[currentIdx + 1]
|
||||
: null;
|
||||
|
||||
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
|
||||
const isSubChapter =
|
||||
"parentChapter" in content &&
|
||||
typeof (content as SsrChapter).parentChapter === "number";
|
||||
const parentChapterNum = isSubChapter
|
||||
? (content as SsrChapter).parentChapter
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/ssr"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Science of Self-Realization
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{parentChapterNum != null && (
|
||||
<span className="text-primary">Chapter {parentChapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<SsrSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<SsrNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<SsrNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SsrSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: SsrSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items by section
|
||||
const frontMatter = items.filter(
|
||||
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
|
||||
);
|
||||
const parents = items.filter((i) => i.type === "parent");
|
||||
const subChapters = items.filter((i) => i.type === "sub-chapter");
|
||||
const backMatter = items.filter((i) => i.slug === "intro-about-the-author");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatter.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters — grouped by parent */}
|
||||
{parents.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{parents.map((parent) => (
|
||||
<div key={parent.slug}>
|
||||
{/* Parent heading — not a link, just a bold label */}
|
||||
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
|
||||
{parent.label}
|
||||
</div>
|
||||
{/* Sub-chapters indented */}
|
||||
{subChapters
|
||||
.filter((sub) => sub.parentChapter === parent.parentChapter)
|
||||
.map((sub) => {
|
||||
const isActive = sub.slug === currentSlug;
|
||||
return (
|
||||
<Link
|
||||
key={sub.slug}
|
||||
href={`/books/ssr/${sub.slug}`}
|
||||
className={cn(
|
||||
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={sub.label}
|
||||
>
|
||||
{sub.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatter.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Back Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatter.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: SsrSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ssr/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SsrNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: SsrSidebarItem | null;
|
||||
next: SsrSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ssr/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ssr/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSsrMeta, getSsrIntroSegments } from "@/lib/ssr";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function SsrPage() {
|
||||
const meta = await getSsrMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getSsrIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (before chapters) and back matter (after)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ssr/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters — hierarchical parent → sub-chapter structure */}
|
||||
{meta.chapters.map((ch) => (
|
||||
<div key={ch.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{ch.number}. {ch.title}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{ch.subChapters.map((sub) => (
|
||||
<li key={sub.slug}>
|
||||
<Link
|
||||
href={`/books/ssr/${sub.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{sub.title}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Back Matter (About the Author) */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ssr/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTlcContent,
|
||||
getTlcSidebarItems,
|
||||
type TlcChapter,
|
||||
type TlcSidebarItem,
|
||||
} from "@/lib/tlc";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function TlcChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getTlcContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getTlcSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as TlcChapter).chapter === "number" &&
|
||||
(content as TlcChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as TlcChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/tlc"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Teachings of Lord Caitanya
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<TlcSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<TlcNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<TlcNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TlcSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: TlcSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: TlcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/tlc/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TlcNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: TlcSidebarItem | null;
|
||||
next: TlcSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/tlc/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/tlc/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTlcMeta, getTlcIntroSegments } from "@/lib/tlc";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const PART_LABELS: Record<number, string> = {
|
||||
1: "Part I",
|
||||
2: "Part II",
|
||||
};
|
||||
|
||||
export default async function TlcPage() {
|
||||
const meta = await getTlcMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getTlcIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground italic">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/tlc/${seg.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts & Chapters */}
|
||||
{meta.parts.map((part) => {
|
||||
const partChapters = part.chapters
|
||||
.map((num) => meta.chapters.find((ch) => ch.number === num))
|
||||
.filter(Boolean) as { number: number; title: string }[];
|
||||
|
||||
return (
|
||||
<div key={part.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{PART_LABELS[part.number] ?? `Part ${part.number}`}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{partChapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/tlc/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTtpContent,
|
||||
getTtpSidebarItems,
|
||||
type TtpChapter,
|
||||
type TtpSidebarItem,
|
||||
} from "@/lib/ttp";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function TtpChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getTtpContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getTtpSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as TtpChapter).chapter === "number" &&
|
||||
(content as TtpChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as TtpChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/books/ttp"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Transcendental Teachings of Prahlāda Mahārāja
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<TtpSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<TtpNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<TtpNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TtpSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: TtpSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: TtpSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ttp/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TtpNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: TtpSidebarItem | null;
|
||||
next: TtpSidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ttp/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ttp/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTtpMeta } from "@/lib/ttp";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function TtpPage() {
|
||||
const meta = await getTtpMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ttp/${ch.number}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (same pattern used across all readers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EssayData = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type SidebarItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
async function getEssay(slug: string): Promise<EssayData | null> {
|
||||
const doc = await prisma.essay.findUnique({ where: { slug } });
|
||||
if (!doc) return null;
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
author: doc.author,
|
||||
year: doc.year,
|
||||
group: doc.group,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
async function getEssaySidebar(): Promise<SidebarItem[]> {
|
||||
return prisma.essay.findMany({
|
||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
||||
select: { slug: true, title: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function EssayPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
|
||||
const essay = await getEssay(slug);
|
||||
if (!essay) notFound();
|
||||
|
||||
const allEssays = await getEssaySidebar();
|
||||
const currentIdx = allEssays.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? allEssays[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < allEssays.length - 1 ? allEssays[currentIdx + 1] : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky wide>
|
||||
<Link
|
||||
href="/essays"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Essays
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{essay.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<EssaySidebar items={allEssays} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<EssayNav prev={prev} next={next} />
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{essay.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<EssayNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EssaySidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: SidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Essays
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={`/essays/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
item.slug === currentSlug
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EssayNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: SidebarItem | null;
|
||||
next: SidebarItem | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/essays/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.title}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/essays/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.title}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
async function getAllEssays() {
|
||||
return prisma.essay.findMany({
|
||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
||||
select: { slug: true, title: true, author: true, year: true, group: true },
|
||||
});
|
||||
}
|
||||
|
||||
export default async function EssaysPage() {
|
||||
const essays = await getAllEssays();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Essays</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Standalone essays by Śrīla Prabhupāda
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{essays.map((essay) => (
|
||||
<li key={essay.slug}>
|
||||
<Link
|
||||
href={`/essays/${essay.slug}`}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{essay.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{essay.author}
|
||||
{essay.year ? ` · ${essay.year}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
-2
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { BookOpen, FileText, BookA } from "lucide-react";
|
||||
import { BookOpen, FileText, BookA, PenLine } from "lucide-react";
|
||||
|
||||
const allowRtf = process.env.ALLOW_RTF === "true";
|
||||
|
||||
@@ -53,6 +53,32 @@ export default function Home() {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Essays */}
|
||||
<Link href="/essays" className="block group">
|
||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||
<PenLine className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-xl font-bold">Essays</CardTitle>
|
||||
<CardDescription>
|
||||
Standalone essays by Śrīla Prabhupāda
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Foundational essays including “Krishna The Reservoir Of
|
||||
Pleasure” and “Who Is Crazy?” — timeless
|
||||
introductions to the science of Kṛṣṇa consciousness.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Glossary */}
|
||||
<Link href="/glossary" className="block group">
|
||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||
@@ -95,7 +121,8 @@ export default function Home() {
|
||||
RTF Document Viewer
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
17 original documents from the original unedited Vedabase
|
||||
17 original documents from the original unedited
|
||||
Vedabase
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for Beyond Birth and Death (BBD).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* BBD is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "bbd";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BbdMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: BbdChapterEntry[];
|
||||
};
|
||||
|
||||
export type BbdChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type BbdIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type BbdChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type BbdSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getBbdMeta(): Promise<BbdMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as BbdMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getBbdIntroSegment(
|
||||
slug: string,
|
||||
): Promise<BbdIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–5).
|
||||
*/
|
||||
export async function getBbdChapter(
|
||||
chapterNum: number,
|
||||
): Promise<BbdChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "5" → chapter
|
||||
*/
|
||||
export async function getBbdContent(
|
||||
slug: string,
|
||||
): Promise<BbdChapter | BbdIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getBbdIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getBbdChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–5
|
||||
*/
|
||||
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getBbdMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: BbdSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Data access layer for Dialectic Spiritualism (DS).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* DS is a large prose/dialogue book with 3 intro segments (front-pages,
|
||||
* foreword, preface) and 13 parent chapters, each containing philosopher
|
||||
* sub-chapters (e.g., "1-1" Socrates, "1-2" Plato, "1-3" Aristotle).
|
||||
* 38 philosopher sub-chapters total.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "ds";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DsMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: DsParentChapter[];
|
||||
};
|
||||
|
||||
export type DsParentChapter = {
|
||||
number: number;
|
||||
title: string;
|
||||
subChapters: DsSubChapterEntry[];
|
||||
};
|
||||
|
||||
export type DsSubChapterEntry = {
|
||||
slug: string; // "1-1", "1-2", etc.
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type DsIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type DsChapter = {
|
||||
book: string;
|
||||
chapter: string; // "1-1", "2-1", etc.
|
||||
parentChapter: number;
|
||||
subChapter: number;
|
||||
title: string;
|
||||
parentTitle: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type DsSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", etc. */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "parent" | "sub-chapter" */
|
||||
type: string;
|
||||
/** Parent chapter number, or null for intro segments */
|
||||
parentChapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
|
||||
*/
|
||||
export async function getDsMeta(): Promise<DsMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as DsMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getDsIntroSegments(): Promise<DsIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getDsIntroSegment(
|
||||
slug: string,
|
||||
): Promise<DsIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters (sub-chapters like "1-1", "2-3", etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A sub-chapter by its slug (e.g. "1-1", "2-3").
|
||||
*/
|
||||
export async function getDsChapter(slug: string): Promise<DsChapter | null> {
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
// Parse parentChapter and subChapter from slug "X-Y"
|
||||
const parts = slug.split("-");
|
||||
const parentChapter = parseInt(parts[0], 10);
|
||||
const subChapter = parseInt(parts[1], 10);
|
||||
|
||||
// Derive parentTitle from meta if available
|
||||
const meta = await getDsMeta();
|
||||
const parentEntry = meta?.chapters.find((ch) => ch.number === parentChapter);
|
||||
const parentTitle = parentEntry?.title ?? "";
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: slug,
|
||||
parentChapter,
|
||||
subChapter,
|
||||
title: doc.title,
|
||||
parentTitle,
|
||||
type: doc.type,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-foreword" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "1-1", "2-3", etc. → sub-chapter
|
||||
*/
|
||||
export async function getDsContent(
|
||||
slug: string,
|
||||
): Promise<DsChapter | DsIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getDsIntroSegment(slug);
|
||||
}
|
||||
|
||||
// Sub-chapter slug like "1-1", "2-3"
|
||||
return getDsChapter(slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list with hierarchical structure.
|
||||
*
|
||||
* Order: front-pages → foreword → preface
|
||||
* → parent-1 (label) → 1-1, 1-2, 1-3 → parent-2 (label) → 2-1, 2-2, ...
|
||||
*
|
||||
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
||||
* "sub-chapter" items are navigable pages.
|
||||
*/
|
||||
export async function getDsSidebarItems(): Promise<DsSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getDsMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
type: true,
|
||||
chapter: true,
|
||||
order: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: DsSidebarItem[] = [];
|
||||
|
||||
// Intro segments (all come before chapters — no back matter for DS)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
parentChapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters — grouped by parent chapter with sub-chapters
|
||||
for (const ch of meta.chapters) {
|
||||
// Parent heading (non-clickable label)
|
||||
items.push({
|
||||
slug: `parent-${ch.number}`,
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "parent",
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
|
||||
// Sub-chapters under this parent
|
||||
for (const sub of ch.subChapters) {
|
||||
items.push({
|
||||
slug: sub.slug,
|
||||
label: sub.title,
|
||||
type: "sub-chapter",
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flat list of navigable sidebar items (excludes "parent" headings).
|
||||
* Used for prev/next navigation.
|
||||
*/
|
||||
export function getNavigableItems(items: DsSidebarItem[]): DsSidebarItem[] {
|
||||
return items.filter((item) => item.type !== "parent");
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Data access layer for Easy Journey to Other Planets (EJ).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* EJ is a simple prose book with 2 chapters and 2 intro segments.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "ej";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EjMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: EjChapterEntry[];
|
||||
};
|
||||
|
||||
export type EjChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type EjIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type EjChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type EjSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", "2" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/nod.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getEjMeta(): Promise<EjMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as EjMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getEjIntroSegment(
|
||||
slug: string,
|
||||
): Promise<EjIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–2).
|
||||
*/
|
||||
export async function getEjChapter(
|
||||
chapterNum: number,
|
||||
): Promise<EjChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "1", "2" → chapter
|
||||
*/
|
||||
export async function getEjContent(
|
||||
slug: string,
|
||||
): Promise<EjChapter | EjIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getEjIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
|
||||
return getEjChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → preface → chapters 1–2
|
||||
*/
|
||||
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getEjMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: EjSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–2 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* EK is a simple prose book with 1 intro segment (front-pages) and 6 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "ek";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EkMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: EkChapterEntry[];
|
||||
};
|
||||
|
||||
export type EkChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type EkIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type EkChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type EkSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "6" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getEkMeta(): Promise<EkMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as EkMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getEkIntroSegment(
|
||||
slug: string,
|
||||
): Promise<EkIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–6).
|
||||
*/
|
||||
export async function getEkChapter(
|
||||
chapterNum: number,
|
||||
): Promise<EkChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "6" → chapter
|
||||
*/
|
||||
export async function getEkContent(
|
||||
slug: string,
|
||||
): Promise<EkChapter | EkIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getEkIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
|
||||
return getEkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–6
|
||||
*/
|
||||
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getEkMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: EkSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Data access layer for The Kṛṣṇa Consciousness Handbook (HKH).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* HKH is a prose book with 2 intro segments (front-pages, introduction)
|
||||
* and 19 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "hkh";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type HkhMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: HkhChapterEntry[];
|
||||
};
|
||||
|
||||
export type HkhChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type HkhIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type HkhChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type HkhSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "intro-introduction", "1", "2", … "7" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getHkhMeta(): Promise<HkhMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as HkhMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getHkhIntroSegments(): Promise<HkhIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getHkhIntroSegment(
|
||||
slug: string,
|
||||
): Promise<HkhIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–19).
|
||||
*/
|
||||
export async function getHkhChapter(
|
||||
chapterNum: number,
|
||||
): Promise<HkhChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 19 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "1" … "7" → chapter
|
||||
*/
|
||||
export async function getHkhContent(
|
||||
slug: string,
|
||||
): Promise<HkhChapter | HkhIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getHkhIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 19) {
|
||||
return getHkhChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → introduction → chapters 1–19
|
||||
*/
|
||||
export async function getHkhSidebarItems(): Promise<HkhSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getHkhMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: HkhSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–19 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Data access layer for Śrī Īśopaniṣad (ISO).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* ISO is unique — it has BOTH verses (in the `verses` collection)
|
||||
* AND prose intro segments (in the `prose_chapters` collection).
|
||||
*
|
||||
* Verses were seeded with:
|
||||
* - book: "iso", division: null
|
||||
* - chapter = mantra number (0 for Invocation, 1-18 for mantras)
|
||||
* - verse = always 1
|
||||
*
|
||||
* Intro segments were seeded with:
|
||||
* - book: "iso", volume: 0, type: "intro-segment"
|
||||
* - slugs like "intro-front-pages", "intro-introduction", "intro-about-the-author"
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "iso";
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mantra title lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MANTRA_TITLES: Record<number, string> = {
|
||||
0: "Invocation",
|
||||
1: "Mantra One",
|
||||
2: "Mantra Two",
|
||||
3: "Mantra Three",
|
||||
4: "Mantra Four",
|
||||
5: "Mantra Five",
|
||||
6: "Mantra Six",
|
||||
7: "Mantra Seven",
|
||||
8: "Mantra Eight",
|
||||
9: "Mantra Nine",
|
||||
10: "Mantra Ten",
|
||||
11: "Mantra Eleven",
|
||||
12: "Mantra Twelve",
|
||||
13: "Mantra Thirteen",
|
||||
14: "Mantra Fourteen",
|
||||
15: "Mantra Fifteen",
|
||||
16: "Mantra Sixteen",
|
||||
17: "Mantra Seventeen",
|
||||
18: "Mantra Eighteen",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type IsoMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalMantras: number;
|
||||
mantras: IsoMantraEntry[];
|
||||
};
|
||||
|
||||
export type IsoMantraEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type IsoIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type IsoVerse = {
|
||||
book: string;
|
||||
book_title: string;
|
||||
mantra: number;
|
||||
title: string;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type IsoSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "invocation", "1", "intro-about-the-author" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "verse" */
|
||||
type: string;
|
||||
/** Mantra number (0 for invocation), or null for non-verse items */
|
||||
mantra: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/nod.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
|
||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
||||
return normalizeParagraphs(purport);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, mantras list, etc.)
|
||||
*/
|
||||
export async function getIsoMeta(): Promise<IsoMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as IsoMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments (from prose_chapters collection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getIsoIntroSegments(): Promise<IsoIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getIsoIntroSegment(
|
||||
slug: string,
|
||||
): Promise<IsoIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verses (from verses collection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single verse/mantra by slug.
|
||||
*
|
||||
* - "invocation" → chapter=0, verse=1
|
||||
* - "1"-"18" → chapter=1–18, verse=1
|
||||
*/
|
||||
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
|
||||
let mantraNum: number;
|
||||
|
||||
if (slug === "invocation") {
|
||||
mantraNum = 0;
|
||||
} else {
|
||||
const num = Number(slug);
|
||||
if (!Number.isInteger(num) || num < 1 || num > 18) return null;
|
||||
mantraNum = num;
|
||||
}
|
||||
|
||||
const doc = await prisma.verse.findFirst({
|
||||
where: { book: BOOK, division: null, chapter: mantraNum, verse: 1 },
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
book_title: doc.bookTitle,
|
||||
mantra: doc.chapter, // chapter field stores the mantra number
|
||||
title: MANTRA_TITLES[doc.chapter] ?? `Mantra ${doc.chapter}`,
|
||||
reference: doc.reference,
|
||||
devanagari: doc.devanagari,
|
||||
transliteration: doc.transliteration,
|
||||
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
|
||||
translation: sanitizeHtml(doc.translation),
|
||||
purport: normalizePurport(doc.purport),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "intro-about-the-author" → intro segment
|
||||
* "invocation" → invocation verse
|
||||
* "1" … "18" → mantra verse
|
||||
*/
|
||||
export async function getIsoContent(
|
||||
slug: string,
|
||||
): Promise<IsoVerse | IsoIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getIsoIntroSegment(slug);
|
||||
}
|
||||
|
||||
if (slug === "invocation") {
|
||||
return getIsoVerse("invocation");
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
||||
return getIsoVerse(String(num));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → introduction → invocation → mantras 1-18 → about-the-author
|
||||
*/
|
||||
export async function getIsoSidebarItems(): Promise<IsoSidebarItem[]> {
|
||||
const meta = await getIsoMeta();
|
||||
if (!meta) return [];
|
||||
|
||||
const items: IsoSidebarItem[] = [];
|
||||
|
||||
// 1. Front pages intro segment
|
||||
items.push({
|
||||
slug: "intro-front-pages",
|
||||
label: "Back Cover & Front Pages",
|
||||
type: "intro-segment",
|
||||
mantra: null,
|
||||
});
|
||||
|
||||
// 2. Introduction (Teachings of the Vedas)
|
||||
items.push({
|
||||
slug: "intro-introduction",
|
||||
label: "Introduction (Teachings of the Vedas)",
|
||||
type: "intro-segment",
|
||||
mantra: null,
|
||||
});
|
||||
|
||||
// 3. Invocation + Mantras 1-18 (from meta.mantras)
|
||||
for (const m of meta.mantras) {
|
||||
if (m.number === 0) {
|
||||
items.push({
|
||||
slug: "invocation",
|
||||
label: "Invocation",
|
||||
type: "verse",
|
||||
mantra: 0,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
slug: String(m.number),
|
||||
label: `${m.number}. ${m.title}`,
|
||||
type: "verse",
|
||||
mantra: m.number,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. About the Author intro segment (at the end)
|
||||
items.push({
|
||||
slug: "intro-about-the-author",
|
||||
label: "About the Author",
|
||||
type: "intro-segment",
|
||||
mantra: null,
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* MG is a simple prose book with 1 intro segment (front-pages) and 7 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "mg";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MgMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: MgChapterEntry[];
|
||||
};
|
||||
|
||||
export type MgChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type MgIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type MgChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type MgSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "7" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getMgMeta(): Promise<MgMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as MgMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getMgIntroSegment(
|
||||
slug: string,
|
||||
): Promise<MgIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–7).
|
||||
*/
|
||||
export async function getMgChapter(
|
||||
chapterNum: number,
|
||||
): Promise<MgChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 7 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "7" → chapter
|
||||
*/
|
||||
export async function getMgContent(
|
||||
slug: string,
|
||||
): Promise<MgChapter | MgIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getMgIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
|
||||
return getMgChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–7
|
||||
*/
|
||||
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getMgMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: MgSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–7 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Data access layer for The Nectar of Devotion (NOD).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* NoD is a prose book (like KB) but has no volume structure —
|
||||
* we use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "nod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NodMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
parts: NodPart[];
|
||||
chapters: NodChapterEntry[];
|
||||
};
|
||||
|
||||
export type NodPart = {
|
||||
number: number;
|
||||
title: string;
|
||||
chapters: number[];
|
||||
};
|
||||
|
||||
export type NodChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type NodIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type NodChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type NodSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-flap", "1", "51", "concluding-words" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" | "conclusion" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/api.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, parts, chapter list, etc.)
|
||||
*/
|
||||
export async function getNodMeta(): Promise<NodMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as NodMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getNodIntroSegments(): Promise<NodIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
|
||||
*/
|
||||
export async function getNodIntroSegment(
|
||||
slug: string,
|
||||
): Promise<NodIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–51).
|
||||
*/
|
||||
export async function getNodChapter(
|
||||
chapterNum: number,
|
||||
): Promise<NodChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 51 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concluding words section.
|
||||
*/
|
||||
export async function getNodConcludingWords(): Promise<NodChapter | null> {
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: "concluding-words",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-flap" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "1" … "51" → chapter
|
||||
* "concluding-words" → concluding section
|
||||
*/
|
||||
export async function getNodContent(
|
||||
slug: string,
|
||||
): Promise<NodChapter | NodIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getNodIntroSegment(slug);
|
||||
}
|
||||
|
||||
if (slug === "concluding-words") {
|
||||
return getNodConcludingWords();
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 51) {
|
||||
return getNodChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: intro segments → chapters 1–51 → concluding words
|
||||
*/
|
||||
export async function getNodSidebarItems(): Promise<NodSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getNodMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: NodSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–51 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Concluding words
|
||||
const cw = allDocs.find((d) => d.type === "conclusion");
|
||||
if (cw) {
|
||||
items.push({
|
||||
slug: cw.slug,
|
||||
label: cw.title,
|
||||
type: "conclusion",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Data access layer for The Nectar of Instruction (NoI).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* NoI has 11 texts (verses) plus intro segments (front-pages, preface, appendixes).
|
||||
*
|
||||
* Verses were seeded with:
|
||||
* - book: "noi", division: null
|
||||
* - chapter = text number (1-11)
|
||||
* - verse = always 1
|
||||
*
|
||||
* Intro segments were seeded with:
|
||||
* - book: "noi", volume: 0, type: "intro-segment"
|
||||
* - slugs like "intro-front-pages", "intro-preface", "intro-appendixes"
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "noi";
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text title lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEXT_TITLES: Record<number, string> = {
|
||||
1: "Text One",
|
||||
2: "Text Two",
|
||||
3: "Text Three",
|
||||
4: "Text Four",
|
||||
5: "Text Five",
|
||||
6: "Text Six",
|
||||
7: "Text Seven",
|
||||
8: "Text Eight",
|
||||
9: "Text Nine",
|
||||
10: "Text Ten",
|
||||
11: "Text Eleven",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NoiMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalTexts: number;
|
||||
texts: NoiTextEntry[];
|
||||
};
|
||||
|
||||
export type NoiTextEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type NoiIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type NoiVerse = {
|
||||
book: string;
|
||||
book_title: string;
|
||||
text: number;
|
||||
title: string;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type NoiSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", etc. */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "verse" */
|
||||
type: string;
|
||||
/** Text number (1-11), or null for non-verse items */
|
||||
text: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/iso.ts, lib/nod.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
/** Normalize purport via normalizeParagraphs. */
|
||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
||||
return normalizeParagraphs(purport);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, texts list, etc.)
|
||||
*/
|
||||
export async function getNoiMeta(): Promise<NoiMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as NoiMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments (from prose_chapters collection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getNoiIntroSegments(): Promise<NoiIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getNoiIntroSegment(
|
||||
slug: string,
|
||||
): Promise<NoiIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verses (texts 1-11, from verses collection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single verse/text by slug.
|
||||
*
|
||||
* - "1"-"11" → chapter=1–11, verse=1
|
||||
*/
|
||||
export async function getNoiVerse(slug: string): Promise<NoiVerse | null> {
|
||||
const textNum = Number(slug);
|
||||
if (!Number.isInteger(textNum) || textNum < 1 || textNum > 11) return null;
|
||||
|
||||
const doc = await prisma.verse.findFirst({
|
||||
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
book_title: doc.bookTitle,
|
||||
text: doc.chapter, // chapter field stores the text number
|
||||
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
|
||||
reference: doc.reference,
|
||||
devanagari: doc.devanagari,
|
||||
transliteration: doc.transliteration,
|
||||
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
|
||||
translation: sanitizeHtml(doc.translation),
|
||||
purport: normalizePurport(doc.purport),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "intro-appendixes" → intro segment
|
||||
* "1" … "11" → verse/text
|
||||
*/
|
||||
export async function getNoiContent(
|
||||
slug: string,
|
||||
): Promise<NoiVerse | NoiIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getNoiIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
||||
return getNoiVerse(String(num));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → preface → texts 1-11 → appendixes
|
||||
*/
|
||||
export async function getNoiSidebarItems(): Promise<NoiSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getNoiMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: NoiSidebarItem[] = [];
|
||||
|
||||
// 1. Intro segments that come before the texts (front-pages, preface — by order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && doc.slug !== "intro-appendixes") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
text: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Texts 1-11 (from meta.texts)
|
||||
for (const t of meta.texts) {
|
||||
items.push({
|
||||
slug: String(t.number),
|
||||
label: `${t.number}. ${t.title}`,
|
||||
type: "verse",
|
||||
text: t.number,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Appendixes intro segment (at the end)
|
||||
const appendixes = allDocs.find((d) => d.slug === "intro-appendixes");
|
||||
if (appendixes) {
|
||||
items.push({
|
||||
slug: appendixes.slug,
|
||||
label: appendixes.title,
|
||||
type: "intro-segment",
|
||||
text: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for On Chanting the Hare Kṛṣṇa Mantra (OCHKM).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* OCHKM is a prose book with 1 intro segment (front-pages) and 4 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "ochkm";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OchkmMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: OchkmChapterEntry[];
|
||||
};
|
||||
|
||||
export type OchkmChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type OchkmIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type OchkmChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type OchkmSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "4" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getOchkmMeta(): Promise<OchkmMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as OchkmMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getOchkmIntroSegment(
|
||||
slug: string,
|
||||
): Promise<OchkmIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–4).
|
||||
*/
|
||||
export async function getOchkmChapter(
|
||||
chapterNum: number,
|
||||
): Promise<OchkmChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "4" → chapter
|
||||
*/
|
||||
export async function getOchkmContent(
|
||||
slug: string,
|
||||
): Promise<OchkmChapter | OchkmIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getOchkmIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
|
||||
return getOchkmChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–4
|
||||
*/
|
||||
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getOchkmMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: OchkmSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–4 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for On the Way to Kṛṣṇa (OWK).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* OWK is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "owk";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OwkMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: OwkChapterEntry[];
|
||||
};
|
||||
|
||||
export type OwkChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type OwkIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type OwkChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type OwkSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getOwkMeta(): Promise<OwkMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as OwkMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getOwkIntroSegment(
|
||||
slug: string,
|
||||
): Promise<OwkIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–5).
|
||||
*/
|
||||
export async function getOwkChapter(
|
||||
chapterNum: number,
|
||||
): Promise<OwkChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "5" → chapter
|
||||
*/
|
||||
export async function getOwkContent(
|
||||
slug: string,
|
||||
): Promise<OwkChapter | OwkIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getOwkIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getOwkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–5
|
||||
*/
|
||||
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getOwkMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: OwkSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for The Perfection of Yoga (POY).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* POY is a prose book with 1 intro segment (front-pages) and 11 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "poy";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PoyMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: PoyChapterEntry[];
|
||||
};
|
||||
|
||||
export type PoyChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type PoyIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type PoyChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type PoySidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "11" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getPoyMeta(): Promise<PoyMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as PoyMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getPoyIntroSegment(
|
||||
slug: string,
|
||||
): Promise<PoyIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–11).
|
||||
*/
|
||||
export async function getPoyChapter(
|
||||
chapterNum: number,
|
||||
): Promise<PoyChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 11 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "11" → chapter
|
||||
*/
|
||||
export async function getPoyContent(
|
||||
slug: string,
|
||||
): Promise<PoyChapter | PoyIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getPoyIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
||||
return getPoyChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–11
|
||||
*/
|
||||
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getPoyMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: PoySidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–11 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Data access layer for Perfect Questions Perfect Answers (PQPA).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* PQPA is a prose/dialogue book with no volume structure —
|
||||
* we use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "pqpa";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PqpaMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: PqpaChapterEntry[];
|
||||
};
|
||||
|
||||
export type PqpaChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type PqpaIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type PqpaChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type PqpaSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "9", "concluding-words" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" | "conclusion" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/api.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getPqpaMeta(): Promise<PqpaMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as PqpaMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getPqpaIntroSegments(): Promise<PqpaIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
|
||||
*/
|
||||
export async function getPqpaIntroSegment(
|
||||
slug: string,
|
||||
): Promise<PqpaIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–9).
|
||||
*/
|
||||
export async function getPqpaChapter(
|
||||
chapterNum: number,
|
||||
): Promise<PqpaChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 9 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concluding words section.
|
||||
*/
|
||||
export async function getPqpaConcludingWords(): Promise<PqpaChapter | null> {
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: "concluding-words",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "intro-about-the-author" → intro segment
|
||||
* "1" … "9" → chapter
|
||||
* "concluding-words" → concluding section
|
||||
*/
|
||||
export async function getPqpaContent(
|
||||
slug: string,
|
||||
): Promise<PqpaChapter | PqpaIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getPqpaIntroSegment(slug);
|
||||
}
|
||||
|
||||
if (slug === "concluding-words") {
|
||||
return getPqpaConcludingWords();
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 9) {
|
||||
return getPqpaChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → introduction → chapters 1–9 → concluding-words → about-the-author
|
||||
*/
|
||||
export async function getPqpaSidebarItems(): Promise<PqpaSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getPqpaMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: PqpaSidebarItem[] = [];
|
||||
|
||||
// Intro segments that come BEFORE chapters (order < 99)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–9 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Concluding words
|
||||
const cw = allDocs.find((d) => d.type === "conclusion");
|
||||
if (cw) {
|
||||
items.push({
|
||||
slug: cw.slug,
|
||||
label: cw.title,
|
||||
type: "conclusion",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for Rāja-vidyā: The King of Knowledge (RV).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* RV is a simple prose book with 1 intro segment (front-pages) and 8 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "rv";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RvMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: RvChapterEntry[];
|
||||
};
|
||||
|
||||
export type RvChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type RvIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type RvChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type RvSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "8" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getRvMeta(): Promise<RvMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as RvMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getRvIntroSegments(): Promise<RvIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getRvIntroSegment(
|
||||
slug: string,
|
||||
): Promise<RvIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–8).
|
||||
*/
|
||||
export async function getRvChapter(
|
||||
chapterNum: number,
|
||||
): Promise<RvChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 8 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "8" → chapter
|
||||
*/
|
||||
export async function getRvContent(
|
||||
slug: string,
|
||||
): Promise<RvChapter | RvIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getRvIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
|
||||
return getRvChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–8
|
||||
*/
|
||||
export async function getRvSidebarItems(): Promise<RvSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getRvMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: RvSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–8 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Data access layer for The Science of Self-Realization (SSR).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* SSR is a large prose book with 6 intro segments (front-pages, dedication,
|
||||
* foreword, introduction, appreciations, about-the-author) and 8 parent
|
||||
* chapters, each containing multiple sub-chapters (e.g., "1-1", "1-2", "2-1").
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "ssr";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SsrMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: SsrParentChapter[];
|
||||
};
|
||||
|
||||
export type SsrParentChapter = {
|
||||
number: number;
|
||||
title: string;
|
||||
subChapters: SsrSubChapterEntry[];
|
||||
};
|
||||
|
||||
export type SsrSubChapterEntry = {
|
||||
slug: string; // "1-1", "1-2", etc.
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type SsrIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type SsrChapter = {
|
||||
book: string;
|
||||
chapter: string; // "1-1", "2-1", etc.
|
||||
parentChapter: number;
|
||||
subChapter: number;
|
||||
title: string;
|
||||
parentTitle: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type SsrSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", "intro-about-the-author" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "parent" | "sub-chapter" */
|
||||
type: string;
|
||||
/** Parent chapter number, or null for intro segments */
|
||||
parentChapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
|
||||
*/
|
||||
export async function getSsrMeta(): Promise<SsrMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as SsrMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getSsrIntroSegments(): Promise<SsrIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getSsrIntroSegment(
|
||||
slug: string,
|
||||
): Promise<SsrIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters (sub-chapters like "1-1", "2-3", etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A sub-chapter by its slug (e.g. "1-1", "2-3").
|
||||
*/
|
||||
export async function getSsrChapter(slug: string): Promise<SsrChapter | null> {
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
// Parse parentChapter and subChapter from slug "X-Y"
|
||||
const parts = slug.split("-");
|
||||
const parentChapter = parseInt(parts[0], 10);
|
||||
const subChapter = parseInt(parts[1], 10);
|
||||
|
||||
// Derive parentTitle from meta if available
|
||||
const meta = await getSsrMeta();
|
||||
const parentEntry = meta?.chapters.find((ch) => ch.number === parentChapter);
|
||||
const parentTitle = parentEntry?.title ?? "";
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: slug,
|
||||
parentChapter,
|
||||
subChapter,
|
||||
title: doc.title,
|
||||
parentTitle,
|
||||
type: doc.type,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-dedication" → intro segment
|
||||
* "intro-about-the-author" → intro segment
|
||||
* "1-1", "2-3", etc. → sub-chapter
|
||||
*/
|
||||
export async function getSsrContent(
|
||||
slug: string,
|
||||
): Promise<SsrChapter | SsrIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getSsrIntroSegment(slug);
|
||||
}
|
||||
|
||||
// Sub-chapter slug like "1-1", "2-3"
|
||||
return getSsrChapter(slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list with hierarchical structure.
|
||||
*
|
||||
* Order: front-pages → dedication → foreword → introduction → appreciations
|
||||
* → parent-1 (label) → 1-1, 1-2, ... → parent-2 (label) → 2-1, 2-2, ...
|
||||
* → about-the-author
|
||||
*
|
||||
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
||||
* "sub-chapter" items are navigable pages.
|
||||
*/
|
||||
export async function getSsrSidebarItems(): Promise<SsrSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getSsrMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
type: true,
|
||||
chapter: true,
|
||||
order: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: SsrSidebarItem[] = [];
|
||||
|
||||
// Intro segments that come BEFORE chapters (order < 99)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && doc.order < 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
parentChapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters — grouped by parent chapter with sub-chapters
|
||||
for (const ch of meta.chapters) {
|
||||
// Parent heading (non-clickable label)
|
||||
items.push({
|
||||
slug: `parent-${ch.number}`,
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "parent",
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
|
||||
// Sub-chapters under this parent
|
||||
for (const sub of ch.subChapters) {
|
||||
items.push({
|
||||
slug: sub.slug,
|
||||
label: sub.title,
|
||||
type: "sub-chapter",
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Intro segments that come AFTER chapters (order >= 99, e.g., about-the-author)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment" && doc.order >= 99) {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
parentChapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flat list of navigable sidebar items (excludes "parent" headings).
|
||||
* Used for prev/next navigation.
|
||||
*/
|
||||
export function getNavigableItems(items: SsrSidebarItem[]): SsrSidebarItem[] {
|
||||
return items.filter((item) => item.type !== "parent");
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Data access layer for Two Essays (TE).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* TE is a collection of 2 standalone essays using the `essays` collection
|
||||
* (not `prose_chapters`). Essays are grouped by `group: "two-essays"`.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - essays (via Prisma Essay model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GROUP = "two-essays";
|
||||
const BOOK = "te";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TeMeta = {
|
||||
group: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
essays: TeEssayEntry[];
|
||||
};
|
||||
|
||||
export type TeEssayEntry = {
|
||||
slug: string;
|
||||
title: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type TeEssay = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type TeSidebarItem = {
|
||||
slug: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/ej.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, essay list, etc.)
|
||||
*/
|
||||
export async function getTeMeta(): Promise<TeMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as TeMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Essays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All essays in the "two-essays" group, ordered by `order`.
|
||||
*/
|
||||
export async function getTeEssays(): Promise<TeEssay[]> {
|
||||
const results = await prisma.essay.findMany({
|
||||
where: { group: GROUP },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
author: doc.author,
|
||||
year: doc.year,
|
||||
group: doc.group,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single essay by slug.
|
||||
*/
|
||||
export async function getTeEssay(slug: string): Promise<TeEssay | null> {
|
||||
const doc = await prisma.essay.findUnique({ where: { slug } });
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
author: doc.author,
|
||||
year: doc.year,
|
||||
group: doc.group,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the sidebar navigation list from meta.
|
||||
*/
|
||||
export async function getTeSidebarItems(): Promise<TeSidebarItem[]> {
|
||||
const meta = await getTeMeta();
|
||||
if (!meta) return [];
|
||||
return meta.essays.map((e) => ({
|
||||
slug: e.slug,
|
||||
label: e.title,
|
||||
}));
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Data access layer for Teachings of Lord Caitanya (TLC).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* TLC is a prose book (like KB) but has no volume structure —
|
||||
* we use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
const BOOK = "tlc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TlcMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
parts: TlcPart[];
|
||||
chapters: TlcChapterEntry[];
|
||||
};
|
||||
|
||||
export type TlcPart = {
|
||||
number: number;
|
||||
title: string;
|
||||
chapters: number[];
|
||||
};
|
||||
|
||||
export type TlcChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type TlcIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type TlcChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type TlcSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-flap", "1", "32" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (mirrors lib/api.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, parts, chapter list, etc.)
|
||||
*/
|
||||
export async function getTlcMeta(): Promise<TlcMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as TlcMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getTlcIntroSegments(): Promise<TlcIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
|
||||
*/
|
||||
export async function getTlcIntroSegment(
|
||||
slug: string,
|
||||
): Promise<TlcIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–32).
|
||||
*/
|
||||
export async function getTlcChapter(
|
||||
chapterNum: number,
|
||||
): Promise<TlcChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 32 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-flap" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "intro-introduction" → intro segment
|
||||
* "1" … "32" → chapter
|
||||
*/
|
||||
export async function getTlcContent(
|
||||
slug: string,
|
||||
): Promise<TlcChapter | TlcIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getTlcIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 32) {
|
||||
return getTlcChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: intro segments → chapters 1–32
|
||||
* (TLC has no concluding-words section.)
|
||||
*/
|
||||
export async function getTlcSidebarItems(): Promise<TlcSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getTlcMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: TlcSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–32 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Data access layer for Transcendental Teachings of Prahlāda Mahārāja (TTP).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* TTP is a prose book with 1 intro segment (front-pages) and 5 chapters.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOK = "ttp";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TtpMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: TtpChapterEntry[];
|
||||
};
|
||||
|
||||
export type TtpChapterEntry = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type TtpIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type TtpChapter = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
/** Union type used in sidebar / table-of-contents navigation. */
|
||||
export type TtpSidebarItem = {
|
||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
|
||||
slug: string;
|
||||
/** Human-readable label for the sidebar */
|
||||
label: string;
|
||||
/** "intro-segment" | "chapter" */
|
||||
type: string;
|
||||
/** Chapter number, or null for non-chapter items */
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||
clean = clean.replace(
|
||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||
"",
|
||||
);
|
||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, chapter list, etc.)
|
||||
*/
|
||||
export async function getTtpMeta(): Promise<TtpMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as TtpMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getTtpIntroSegments(): Promise<TtpIntroSegment[]> {
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return results.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getTtpIntroSegment(
|
||||
slug: string,
|
||||
): Promise<TtpIntroSegment | null> {
|
||||
// DB stores with "intro-" prefix; accept either form
|
||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
book: doc.book,
|
||||
order: doc.order,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–5).
|
||||
*/
|
||||
export async function getTtpChapter(
|
||||
chapterNum: number,
|
||||
): Promise<TtpChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: {
|
||||
book: BOOK,
|
||||
volume: NO_VOLUME,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "5" → chapter
|
||||
*/
|
||||
export async function getTtpContent(
|
||||
slug: string,
|
||||
): Promise<TtpChapter | TtpIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getTtpIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getTtpChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–5
|
||||
*/
|
||||
export async function getTtpSidebarItems(): Promise<TtpSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
getTtpMeta(),
|
||||
prisma.proseChapter.findMany({
|
||||
where: { book: BOOK, volume: NO_VOLUME },
|
||||
orderBy: { order: "asc" },
|
||||
select: { slug: true, title: true, type: true, chapter: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
const items: TtpSidebarItem[] = [];
|
||||
|
||||
// Intro segments (from DB, in order)
|
||||
for (const doc of allDocs) {
|
||||
if (doc.type === "intro-segment") {
|
||||
items.push({
|
||||
slug: doc.slug,
|
||||
label: doc.title,
|
||||
type: "intro-segment",
|
||||
chapter: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
|
||||
for (const ch of meta.chapters) {
|
||||
items.push({
|
||||
slug: String(ch.number),
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "chapter",
|
||||
chapter: ch.number,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -93,6 +93,31 @@ model ProseChapter {
|
||||
@@map("prose_chapters")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Essay — standalone essays (not part of any book's chapter structure)
|
||||
//
|
||||
// slug: URL-friendly identifier (e.g. "krishna-reservoir-of-pleasure")
|
||||
// title: essay title
|
||||
// author: author name
|
||||
// year: publication year
|
||||
// group: optional grouping key (e.g. "two-essays" for essays published together)
|
||||
// order: sort order within a group
|
||||
// paragraphs: array of HTML paragraph strings
|
||||
// ---------------------------------------------------------------------------
|
||||
model Essay {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
title String
|
||||
author String
|
||||
year Int?
|
||||
group String?
|
||||
order Int
|
||||
paragraphs String[]
|
||||
|
||||
@@index([group])
|
||||
@@map("essays")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GlossaryEntry — individual glossary term + definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12027,8 +12027,7 @@ Boston, Mass. 02134</p>
|
||||
<p class="p6">O my Lord! When will my eyes be decorated with tears of love, flowing constantly while I chant Your holy name? When will my words be choked up when uttering the holy name? And when will all the holes of hair on my body have eruptions by the recitation of Your name?</p>
|
||||
<p class="p6">O Govinda! Feeling Your separation I am considering a moment twelve years or more, and tears are flowing from my eyes like torrents of rain. I am feeling all vacant in the world in the absence of You.</p>
|
||||
<p class="p6">I do not know anyone except Kåñëa as my Lord, and He will always remain as such, even if He handles me roughly by His embrace or makes me broken-hearted by not being present before me. He is completely free to do anything and everything, but He is always my worshipable Lord, unconditionally.</p>
|
||||
<p class="p12"><b>The <br>
|
||||
Krsna Consciousness Handbook (1971)</b></p>
|
||||
<p class="p12"><b>The Krsna Consciousness Handbook (1971)</b></p>
|
||||
<p class="p19">FOR THE YEAR 484, <br>
|
||||
CAITANYA ERA <br>
|
||||
(March 24, 1970 March 12, 1971)</p>
|
||||
@@ -13322,7 +13321,7 @@ ADDRESS _________________________</p>
|
||||
Former Prime Minister of India</p>
|
||||
<p class="p12"><b>The Science of Self-Realization (1977)</b></p>
|
||||
<p class="p128">Readers interested in the subject matter of this book are invited by the International Society for Krishna Consciousness to correspond with its Secretary.</p>
|
||||
<p class="p25">I<b>nternational Society for Krishna Consciousness</b></p>
|
||||
<p class="p25">International Society for Krishna Consciousness</p>
|
||||
<p class="p25"><b>3764 Watseka Avenue<br>
|
||||
Los Angeles, California 90034</b></p>
|
||||
<p class="p128"><span class="s4"><br>
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Beyond Birth and Death (1974) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 5 chapters.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/bbd/intro.json — Book metadata
|
||||
lib/jsons/books/bbd/intro/*.json — Intro segments
|
||||
lib/jsons/books/bbd/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/bbd_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for BBD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53"])
|
||||
|
||||
# Bold quote paragraphs — include as body
|
||||
BOLD_QUOTE_CLASSES = frozenset(["p56", "p30"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Table of contents
|
||||
TOC_CLASS = "p80"
|
||||
|
||||
# END marker class
|
||||
END_MARKER_CLASS = "p81"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p70"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_bbd_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the BBD content.
|
||||
|
||||
Start: <p class="p12"> containing "Beyond Birth and Death" and "1974"
|
||||
End: <p class="p81"><b>END</b></p> or next <p class="p12"> after start
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Beyond Birth and Death" in text and "1974" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after BBD start = end of BBD
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Beyond Birth and Death (1974)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for END marker (p81 with text "END") as end boundary
|
||||
for i in range(start_idx + 1, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == END_MARKER_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if text.upper() == "END":
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all BBD chapter bookmark tags (p13) in the BBD range.
|
||||
|
||||
Returns list of (paragraph_index, chapter_number, chapter_title).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
m = re.match(r"BBD\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
ch_title = m.group(2).strip()
|
||||
bookmarks.append((i, ch_num, ch_title))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the BBD content.
|
||||
Groups consecutive p53 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip END marker (p81)
|
||||
if cls == END_MARKER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Table of contents (p80) — include in front-pages
|
||||
if cls == TOC_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Bold quote paragraphs (p56, p30) — include as body
|
||||
if cls in BOLD_QUOTE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_bbd(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Beyond Birth and Death (1974) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find BBD boundaries
|
||||
bbd_start, bbd_end = find_bbd_boundaries(all_paragraphs)
|
||||
print(f" BBD content: paragraphs {bbd_start} to {bbd_end}")
|
||||
print(f" Total paragraphs in range: {bbd_end - bbd_start}")
|
||||
|
||||
# Step 2: Find all chapter bookmarks
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, bbd_start, bbd_end)
|
||||
print(f" Found {len(chapter_bookmarks)} BBD chapter bookmarks")
|
||||
for pos, ch_num, ch_title in chapter_bookmarks:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (bbd_start) to first chapter bookmark (BBD 1)
|
||||
intro_segments = []
|
||||
|
||||
if chapter_bookmarks:
|
||||
front_pages_end = chapter_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = bbd_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, bbd_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "bbd",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or bbd_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = bbd_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "bbd",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "bbd")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Beyond Birth and Death (1974)...\n")
|
||||
result = extract_bbd(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "bbd",
|
||||
"title": "Beyond Birth and Death",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1974,
|
||||
"description": "An introduction to the science of the self, transcending the cycle of birth and death through Kṛṣṇa consciousness.",
|
||||
"totalChapters": 5,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 5 chapters present
|
||||
expected_chapters = set(range(1, 6))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 5 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,750 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Easy Journey to Other Planets (1970 Edition) from other.html.
|
||||
|
||||
The book has 2 intro segments (front-pages, preface) and 2 chapters.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/ej/intro.json — Book metadata
|
||||
lib/jsons/books/ej/intro/*.json — Intro segments
|
||||
lib/jsons/books/ej/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/ej_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for EJ'70
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Section bookmark class (Preface, Glossary)
|
||||
SECTION_BOOKMARK_CLASS = "p64"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p49"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p45", "p63", "p72", "p71"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p10", "p73"])
|
||||
|
||||
# Section heading (bold) — "Preface", "1 Anti-material Worlds", etc.
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
|
||||
# Front-pages specific classes
|
||||
BOOK_LIST_CLASS = "p68"
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
BOOK_TITLE_CLASS = "p69"
|
||||
|
||||
# Price text — skip
|
||||
PRICE_CLASS = "p42"
|
||||
|
||||
# Glossary header — end boundary marker
|
||||
GLOSSARY_HEADER_CLASS = "p19"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p70"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INTRO_SEGMENTS = [
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"order": 1,
|
||||
"boundary_type": "start", # from book start to first section bookmark
|
||||
},
|
||||
{
|
||||
"slug": "preface",
|
||||
"title": "Preface",
|
||||
"order": 2,
|
||||
"bookmark_text": "EJ'70:Preface",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_ej_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the EJ'70 content.
|
||||
|
||||
Start: <p class="p12"> containing "Easy Journey" and "1970"
|
||||
End: next <p class="p12"> after start, or the EJ'70:Glossary bookmark
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Easy Journey" in text and "1970" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after EJ start = end of EJ
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Easy Journey to other planets (1970)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for the EJ'70:Glossary bookmark as an end boundary
|
||||
for i in range(start_idx + 1, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == SECTION_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "EJ'70:Glossary" in text:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all bookmark tags (p64 and p49) in the EJ range.
|
||||
|
||||
Returns list of (paragraph_index, bookmark_text, bookmark_type).
|
||||
bookmark_type is 'section' for p64, 'chapter' for p49.
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == SECTION_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "EJ'70:" in text:
|
||||
bookmarks.append((i, text, "section"))
|
||||
elif cls == CHAPTER_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "EJ'70:" in text:
|
||||
bookmarks.append((i, text, "chapter"))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str) -> tuple:
|
||||
"""Parse a chapter bookmark like "EJ'70:1 Anti-material Worlds".
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"EJ'70:(\d+)\s+(.+)", text.strip())
|
||||
if m:
|
||||
return int(m.group(1)), m.group(2).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_section_bookmark(text: str) -> str:
|
||||
"""Parse a section bookmark like "EJ'70:Preface" or "EJ'70:Glossary".
|
||||
|
||||
Returns the section name or None.
|
||||
"""
|
||||
m = re.match(r"EJ'70:(\w+)", text.strip())
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the EJ content.
|
||||
Groups consecutive p10/p73 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (both p64 and p49)
|
||||
if cls in (SECTION_BOOKMARK_CLASS, CHAPTER_BOOKMARK_CLASS):
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip price text (p42)
|
||||
if cls == PRICE_CLASS:
|
||||
continue
|
||||
|
||||
# Skip glossary header (p19)
|
||||
if cls == GLOSSARY_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p10, p73) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Section heading (p31) — include as paragraph
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Book title bold (p69) — include in front-pages
|
||||
if cls == BOOK_TITLE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include in front-pages
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Book list items (p68) — include in front-pages
|
||||
if cls == BOOK_LIST_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p45, p63, p72, p71)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_ej(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Easy Journey to Other Planets (1970) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find EJ boundaries
|
||||
ej_start, ej_end = find_ej_boundaries(all_paragraphs)
|
||||
print(f" EJ'70 content: paragraphs {ej_start} to {ej_end}")
|
||||
print(f" Total paragraphs in range: {ej_end - ej_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, ej_start, ej_end)
|
||||
print(f" Found {len(bookmarks)} EJ'70 bookmarks")
|
||||
for pos, text, btype in bookmarks:
|
||||
print(f" [{btype}] paragraph {pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks into section vs chapter
|
||||
section_bookmarks = [] # (para_idx, bookmark_text)
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
|
||||
for pos, text, btype in bookmarks:
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
else:
|
||||
section_name = parse_section_bookmark(text)
|
||||
if section_name and section_name != "Glossary":
|
||||
section_bookmarks.append((pos, text))
|
||||
|
||||
print(f" Section bookmarks: {len(section_bookmarks)}")
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
|
||||
# Step 4: Collect all boundary positions for range detection
|
||||
# These are all the positions where a new section/chapter starts
|
||||
all_boundary_positions = sorted(
|
||||
[pos for pos, _ in section_bookmarks] + [pos for pos, _, _ in chapter_bookmarks]
|
||||
)
|
||||
|
||||
# Step 5: Extract intro segments
|
||||
intro_segments = []
|
||||
|
||||
# --- Front Pages ---
|
||||
# From book start (ej_start) to first bookmark (Preface)
|
||||
front_pages_end = all_boundary_positions[0] if all_boundary_positions else ej_end
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, ej_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "ej",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Preface ---
|
||||
# From EJ'70:Preface bookmark to first chapter bookmark
|
||||
preface_pos = None
|
||||
for pos, text in section_bookmarks:
|
||||
if "Preface" in text:
|
||||
preface_pos = pos
|
||||
break
|
||||
|
||||
if preface_pos is not None:
|
||||
# Content starts after the bookmark
|
||||
preface_start = preface_pos + 1
|
||||
|
||||
# Content ends at the first chapter bookmark
|
||||
preface_end = ej_end
|
||||
for s in all_boundary_positions:
|
||||
if s > preface_pos:
|
||||
preface_end = s
|
||||
break
|
||||
|
||||
preface_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, preface_start, preface_end
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "preface",
|
||||
"title": "Preface",
|
||||
"type": "intro-segment",
|
||||
"book": "ej",
|
||||
"order": 2,
|
||||
"paragraphs": preface_paragraphs,
|
||||
}
|
||||
)
|
||||
else:
|
||||
print(" WARNING: Could not find Preface bookmark!")
|
||||
|
||||
# Step 6: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or ej_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = ej_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "ej",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 7: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ej")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Easy Journey to Other Planets (1970 Edition)...\n")
|
||||
result = extract_ej(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "ej",
|
||||
"title": "Easy Journey to Other Planets",
|
||||
"subtitle": "By Practice of Supreme Yoga",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1970,
|
||||
"description": "How one can transfer oneself to other planets by the process of bhakti-yoga.",
|
||||
"totalChapters": 2,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(intro_segments) > 1:
|
||||
print(f"\n--- Sample: Preface ---")
|
||||
preface = intro_segments[1]
|
||||
for i, p in enumerate(preface["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 2 chapters present
|
||||
expected_chapters = set(range(1, 3))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 2 chapters present")
|
||||
|
||||
# Check all 2 intro segments present
|
||||
expected_intros = {"front-pages", "preface"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 2 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "Gītā" in all_text or "gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
# Look for patterns that are very likely ScaGoudy artifacts
|
||||
# e.g., "Kåñëa" instead of "Kṛṣṇa"
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,655 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Elevation to Kṛṣṇa Consciousness (1973) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 6 chapters.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/ek/intro.json — Book metadata
|
||||
lib/jsons/books/ek/intro/*.json — Intro segments
|
||||
lib/jsons/books/ek/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/ek_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for EK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Source reference
|
||||
SOURCE_REF_CLASS = "p74"
|
||||
|
||||
# Glossary header — end boundary marker
|
||||
GLOSSARY_HEADER_CLASS = "p19"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p70"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_ek_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the EK content.
|
||||
|
||||
Start: <p class="p12"> containing "Elevation to" and "1973"
|
||||
End: next <p class="p12"> after start, or <p class="p19">GLOSSARY</p>
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Elevation" in text and "1973" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after EK start = end of EK
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Elevation to Kṛṣṇa Consciousness (1973)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for GLOSSARY as end boundary (p19 with text "GLOSSARY")
|
||||
for i in range(start_idx + 1, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == GLOSSARY_HEADER_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if text.upper().startswith("GLOSSARY"):
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all EK chapter bookmark tags (p13) in the EK range.
|
||||
|
||||
Returns list of (paragraph_index, chapter_number, chapter_title).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
m = re.match(r"EK\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
ch_title = m.group(2).strip()
|
||||
bookmarks.append((i, ch_num, ch_title))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the EK content.
|
||||
Groups consecutive p53 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip glossary header (p19)
|
||||
if cls == GLOSSARY_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Source reference (p74) — include as paragraph
|
||||
if cls == SOURCE_REF_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_ek(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Elevation to Kṛṣṇa Consciousness (1973) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find EK boundaries
|
||||
ek_start, ek_end = find_ek_boundaries(all_paragraphs)
|
||||
print(f" EK content: paragraphs {ek_start} to {ek_end}")
|
||||
print(f" Total paragraphs in range: {ek_end - ek_start}")
|
||||
|
||||
# Step 2: Find all chapter bookmarks
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, ek_start, ek_end)
|
||||
print(f" Found {len(chapter_bookmarks)} EK chapter bookmarks")
|
||||
for pos, ch_num, ch_title in chapter_bookmarks:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (ek_start) to first chapter bookmark (EK 1)
|
||||
intro_segments = []
|
||||
|
||||
if chapter_bookmarks:
|
||||
front_pages_end = chapter_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = ek_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, ek_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "ek",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or ek_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = ek_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "ek",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ek")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Elevation to Kṛṣṇa Consciousness (1973)...\n")
|
||||
result = extract_ek(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "ek",
|
||||
"title": "Elevation to Kṛṣṇa Consciousness",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1973,
|
||||
"description": "How to elevate oneself from material consciousness to the spiritual platform of Kṛṣṇa consciousness.",
|
||||
"totalChapters": 6,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 6 chapters present
|
||||
expected_chapters = set(range(1, 7))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 6 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,932 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract The Kṛṣṇa Consciousness Handbook (1971) from other.html.
|
||||
|
||||
The book has 2 intro segments (front-pages, introduction) and 7 chapters.
|
||||
|
||||
Main chapter bookmarks (p13 class):
|
||||
- HKH: Introduction
|
||||
- HKH 1: A Brief History of ISKCON
|
||||
- HKH 2: Teachings of ISKCON
|
||||
- HKH 3: Brief Biography of the Spiritual Master
|
||||
- HKH 4: Paramparā
|
||||
- HKH 5: Ministers for Kṛṣṇa Consciousness: ...
|
||||
- HKH 6: Transcendental Aspects
|
||||
- HKH 7: The Qualifications and Curriculum of the Divinity Student
|
||||
|
||||
Sub-section bookmarks (p49 class):
|
||||
- HKH 6a–6f within chapter 6
|
||||
- HKH 7a–7l within chapter 7
|
||||
- HKH 7m is the END boundary (book catalog, not handbook content)
|
||||
|
||||
Output:
|
||||
lib/jsons/books/hkh/intro.json — Book metadata
|
||||
lib/jsons/books/hkh/intro/front-pages.json
|
||||
lib/jsons/books/hkh/intro/introduction.json
|
||||
lib/jsons/books/hkh/chapters/01.json through 07.json
|
||||
|
||||
Usage:
|
||||
python3 scripts/hkh_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for HKH
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Chapter bookmark class (main chapters)
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Sub-section bookmark class
|
||||
SUBSECTION_BOOKMARK_CLASS = "p49"
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53"])
|
||||
|
||||
# Classes to skip entirely
|
||||
SKIP_CLASSES = frozenset(["p9", "p70"])
|
||||
|
||||
# Body paragraph classes — be permissive, handle everything not otherwise special
|
||||
BODY_CLASSES = frozenset(
|
||||
[
|
||||
"p6",
|
||||
"p67",
|
||||
"p104",
|
||||
"p101",
|
||||
"p46",
|
||||
"p48",
|
||||
"p103",
|
||||
"p105",
|
||||
"p36",
|
||||
"p37",
|
||||
"p24",
|
||||
"p44",
|
||||
"p28",
|
||||
"p109",
|
||||
"p110",
|
||||
"p111",
|
||||
"p112",
|
||||
"p113",
|
||||
"p114",
|
||||
"p115",
|
||||
"p95",
|
||||
"p82",
|
||||
"p25",
|
||||
"p118",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_hkh_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the HKH content.
|
||||
|
||||
Start: <p class="p12"> containing "Krsna Consciousness Handbook" and "1971"
|
||||
End: <p class="p49"> containing "HKH 7m:" (book catalog section)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
text = p.get_text().strip()
|
||||
|
||||
# Find start
|
||||
if (
|
||||
cls == BOOK_HEADER_CLASS
|
||||
and "Krsna Consciousness Handbook" in text
|
||||
and "1971" in text
|
||||
):
|
||||
start_idx = i
|
||||
|
||||
# Find end: p49 containing "HKH 7m:"
|
||||
if start_idx is not None and cls == SUBSECTION_BOOKMARK_CLASS:
|
||||
if re.search(r"HKH\s+7m:", text):
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'The Krsna Consciousness Handbook (1971)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all HKH chapter/intro bookmark tags (p13) in the HKH range.
|
||||
|
||||
Returns list of (paragraph_index, chapter_number_or_None, chapter_title, bookmark_type).
|
||||
bookmark_type is "intro" for "HKH: Introduction" and "chapter" for numbered chapters.
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
|
||||
# Check for introduction bookmark
|
||||
if re.match(r"HKH:\s+Introduction", text.strip()):
|
||||
bookmarks.append((i, None, "Introduction", "intro"))
|
||||
continue
|
||||
|
||||
# Check for numbered chapter bookmark (handle multi-line titles)
|
||||
# Collapse newlines for matching
|
||||
collapsed = re.sub(r"\s+", " ", text.strip())
|
||||
m = re.match(r"HKH\s+(\d+):\s+(.+)", collapsed)
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
ch_title = m.group(2).strip()
|
||||
bookmarks.append((i, ch_num, ch_title, "chapter"))
|
||||
|
||||
return bookmarks
|
||||
|
||||
|
||||
def find_subsection_bookmarks(
|
||||
all_paragraphs: list, start_idx: int, end_idx: int
|
||||
) -> list:
|
||||
"""Find all HKH sub-section bookmark tags (p49) in a range.
|
||||
|
||||
Returns list of (paragraph_index, subsection_code, subsection_title).
|
||||
e.g. (12149, "6a", "Śikhā")
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != SUBSECTION_BOOKMARK_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
|
||||
# Match HKH Nx: Title patterns
|
||||
collapsed = re.sub(r"\s+", " ", text.strip())
|
||||
m = re.match(r"HKH\s+(\d+[a-z]):\s*(.*)", collapsed)
|
||||
if m:
|
||||
code = m.group(1)
|
||||
title = m.group(2).strip()
|
||||
# Clean up title — remove surrounding <i> artifacts and leading/trailing whitespace
|
||||
title = re.sub(r"</?i>", "", title).strip()
|
||||
bookmarks.append((i, code, title))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
subsection_bookmarks: list = None,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the HKH content.
|
||||
Groups consecutive p53 verse pādas into centered italic divs.
|
||||
Sub-section bookmarks (p49) are converted to bold heading paragraphs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
if subsection_bookmarks is None:
|
||||
subsection_bookmarks = []
|
||||
|
||||
# Build a set of subsection bookmark indices for quick lookup
|
||||
subsection_indices = {b[0] for b in subsection_bookmarks}
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip chapter bookmark tags (p13) — boundaries only
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Handle sub-section bookmarks (p49) — convert to bold headings
|
||||
if cls == SUBSECTION_BOOKMARK_CLASS:
|
||||
flush_verse()
|
||||
if i in subsection_indices:
|
||||
# Find the matching subsection bookmark
|
||||
for bm in subsection_bookmarks:
|
||||
if bm[0] == i:
|
||||
sub_title = bm[2]
|
||||
if sub_title:
|
||||
result.append(f"<b>{sub_title}</b>")
|
||||
break
|
||||
# Skip p49 tags that aren't in subsection_bookmarks (shouldn't happen in range)
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Section headings (p31, p27) — include as paragraphs
|
||||
if cls in ("p31", "p27"):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Misc text (p19, p21) — include
|
||||
if cls in ("p19", "p21"):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Contents heading (p69) — include
|
||||
if cls == "p69":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Contact info (p102) — include in front pages
|
||||
if cls == "p102":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs and everything else — be permissive
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(f'<div style="text-align:center"><i>{inner}</i></div>')
|
||||
else:
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clean chapter titles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Map of chapter number to clean Unicode title
|
||||
CLEAN_TITLES = {
|
||||
1: "A Brief History of ISKCON",
|
||||
2: "Teachings of ISKCON",
|
||||
3: "Brief Biography of the Spiritual Master",
|
||||
4: "Paramparā",
|
||||
5: "Ministers for Kṛṣṇa Consciousness",
|
||||
6: "Transcendental Aspects",
|
||||
7: "The Qualifications and Curriculum of the Divinity Student",
|
||||
}
|
||||
|
||||
|
||||
def clean_chapter_title(ch_num: int, raw_title: str) -> str:
|
||||
"""Return a clean Unicode title for the chapter."""
|
||||
if ch_num in CLEAN_TITLES:
|
||||
return CLEAN_TITLES[ch_num]
|
||||
# Fallback: decode ScaGoudy from raw title
|
||||
return decode_scagoudy(raw_title)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_hkh(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Kṛṣṇa Consciousness Handbook (1971) from the parsed HTML.
|
||||
|
||||
Chapter 6 ("Transcendental Aspects") sub-sections (Śikhā, Beads, Ārātrika,
|
||||
Garlands, Deities, Diet) are promoted to their own chapters (6-11), and the
|
||||
old chapter 7 becomes chapter 12. This gives 12 total chapters.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find HKH boundaries
|
||||
hkh_start, hkh_end = find_hkh_boundaries(all_paragraphs)
|
||||
print(f" HKH content: paragraphs {hkh_start} to {hkh_end}")
|
||||
print(f" Total paragraphs in range: {hkh_end - hkh_start}")
|
||||
|
||||
# Step 2: Find all chapter bookmarks
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, hkh_start, hkh_end)
|
||||
print(f" Found {len(chapter_bookmarks)} HKH chapter bookmarks")
|
||||
for pos, ch_num, ch_title, bm_type in chapter_bookmarks:
|
||||
if bm_type == "intro":
|
||||
print(f" [introduction] paragraph {pos}: {ch_title}")
|
||||
else:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Find all sub-section bookmarks in the full range
|
||||
all_subsections = find_subsection_bookmarks(all_paragraphs, hkh_start, hkh_end)
|
||||
print(f" Found {len(all_subsections)} sub-section bookmarks")
|
||||
|
||||
# Step 4: Separate intro bookmark from chapter bookmarks
|
||||
intro_bookmark = None
|
||||
numbered_bookmarks = []
|
||||
for bm in chapter_bookmarks:
|
||||
if bm[3] == "intro":
|
||||
intro_bookmark = bm
|
||||
else:
|
||||
numbered_bookmarks.append(bm)
|
||||
|
||||
# Step 5: Extract front pages
|
||||
intro_segments = []
|
||||
|
||||
if intro_bookmark:
|
||||
front_pages_end = intro_bookmark[0]
|
||||
elif numbered_bookmarks:
|
||||
front_pages_end = numbered_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = hkh_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, hkh_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "hkh",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 6: Extract introduction segment
|
||||
if intro_bookmark:
|
||||
intro_start = intro_bookmark[0] + 1
|
||||
if numbered_bookmarks:
|
||||
intro_end = numbered_bookmarks[0][0]
|
||||
else:
|
||||
intro_end = hkh_end
|
||||
|
||||
intro_paragraphs = extract_paragraphs(all_paragraphs, intro_start, intro_end)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "hkh",
|
||||
"order": 2,
|
||||
"paragraphs": intro_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 7: Build the effective chapter list.
|
||||
#
|
||||
# Chapters 1-5 stay as-is from p13 bookmarks.
|
||||
# Chapter 6 ("Transcendental Aspects") is SPLIT: its p49 sub-sections
|
||||
# (6a=Śikhā, 6b=Beads, 6c=Ārātrika, 6d=Garlands, 6e=Deities, 6f=Diet)
|
||||
# each become their own chapter (renumbered 6-11).
|
||||
# Old chapter 7 is also SPLIT: 7a+7b stay together as "The Qualifications
|
||||
# and Curriculum of the Divinity Student" (chapter 12), and 7c-7l each
|
||||
# become their own chapters (13-21). Total: 21 chapters.
|
||||
|
||||
# Identify the chapter-6 and chapter-7 bookmarks
|
||||
ch6_bm = None
|
||||
ch7_bm = None
|
||||
for bm in numbered_bookmarks:
|
||||
if bm[1] == 6:
|
||||
ch6_bm = bm
|
||||
elif bm[1] == 7:
|
||||
ch7_bm = bm
|
||||
|
||||
# Find chapter 6's sub-section bookmarks (6a through 6f)
|
||||
ch6_subsections = []
|
||||
if ch6_bm and ch7_bm:
|
||||
ch6_range_start = ch6_bm[0]
|
||||
ch6_range_end = ch7_bm[0]
|
||||
ch6_subsections = [
|
||||
s for s in all_subsections if ch6_range_start <= s[0] < ch6_range_end
|
||||
]
|
||||
|
||||
# Find chapter 7's sub-section bookmarks (7a through 7l)
|
||||
ch7_subsections = []
|
||||
if ch7_bm:
|
||||
ch7_range_start = ch7_bm[0]
|
||||
ch7_range_end = hkh_end
|
||||
ch7_subsections = [
|
||||
s for s in all_subsections if ch7_range_start <= s[0] < ch7_range_end
|
||||
]
|
||||
|
||||
# Sub-sections to skip entirely:
|
||||
# 7a = "Entrance Qualifications", 7b = "Curriculum" (administrative)
|
||||
# 7h = "ISKCON Press" (organizational, not spiritual content)
|
||||
CH7_SKIP_CODES = {"7a", "7b", "7h"}
|
||||
ch7_split = [
|
||||
s
|
||||
for s in ch7_subsections
|
||||
if s[1].split(":")[0].strip().replace("HKH ", "") not in CH7_SKIP_CODES
|
||||
]
|
||||
|
||||
chapters = []
|
||||
output_ch_num = 0
|
||||
|
||||
for idx, (ch_pos, ch_num, ch_title, _) in enumerate(numbered_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
if idx + 1 < len(numbered_bookmarks):
|
||||
content_end = numbered_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = hkh_end
|
||||
|
||||
# --- Special handling for chapter 6: split into sub-section chapters ---
|
||||
if ch_num == 6 and ch6_subsections:
|
||||
for sub_idx, (sub_pos, sub_code, sub_title) in enumerate(ch6_subsections):
|
||||
output_ch_num += 1
|
||||
sub_start = sub_pos + 1
|
||||
if sub_idx + 1 < len(ch6_subsections):
|
||||
sub_end = ch6_subsections[sub_idx + 1][0]
|
||||
else:
|
||||
sub_end = content_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, sub_start, sub_end)
|
||||
clean_title = decode_scagoudy(sub_title).strip()
|
||||
|
||||
chapters.append(
|
||||
{
|
||||
"book": "hkh",
|
||||
"chapter": output_ch_num,
|
||||
"title": clean_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# --- Special handling for chapter 7: skip 7a+7b (curriculum), split 7c-7l ---
|
||||
if ch_num == 7 and ch7_split:
|
||||
# Skip excluded sub-sections (7a, 7b, 7h).
|
||||
# Only emit remaining sub-sections as separate chapters.
|
||||
|
||||
for sub_idx, (sub_pos, sub_code, sub_title) in enumerate(ch7_split):
|
||||
output_ch_num += 1
|
||||
sub_start = sub_pos + 1
|
||||
if sub_idx + 1 < len(ch7_split):
|
||||
sub_end = ch7_split[sub_idx + 1][0]
|
||||
else:
|
||||
sub_end = content_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, sub_start, sub_end)
|
||||
clean_title = decode_scagoudy(sub_title).strip()
|
||||
|
||||
chapters.append(
|
||||
{
|
||||
"book": "hkh",
|
||||
"chapter": output_ch_num,
|
||||
"title": clean_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# --- Normal chapters (1-5) ---
|
||||
output_ch_num += 1
|
||||
|
||||
paragraphs = extract_paragraphs(
|
||||
all_paragraphs,
|
||||
content_start,
|
||||
content_end,
|
||||
)
|
||||
|
||||
clean_title = clean_chapter_title(ch_num, ch_title)
|
||||
|
||||
chapters.append(
|
||||
{
|
||||
"book": "hkh",
|
||||
"chapter": output_ch_num,
|
||||
"title": clean_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 8: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "hkh")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting The Kṛṣṇa Consciousness Handbook (1971)...\n")
|
||||
result = extract_hkh(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "hkh",
|
||||
"title": "The Kṛṣṇa Consciousness Handbook",
|
||||
"author": "International Society for Krishna Consciousness",
|
||||
"year": 1971,
|
||||
"description": "The first annual handbook of ISKCON, covering the history, teachings, curriculum, and practices of Kṛṣṇa consciousness.",
|
||||
"totalChapters": 7,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(intro_segments) > 1:
|
||||
print(f"\n--- Sample: Introduction ---")
|
||||
intro_seg = intro_segments[1]
|
||||
for i, p in enumerate(intro_seg["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 7 chapters present
|
||||
expected_chapters = set(range(1, 8))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 7 chapters present")
|
||||
|
||||
# Check all 2 intro segments present
|
||||
expected_intros = {"front-pages", "introduction"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 2 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
if "ISKCON" in all_text:
|
||||
print(f" ✅ 'ISKCON' found")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Check sub-section headings in chapters 6 and 7
|
||||
print(f"\n--- Sub-section Heading Check ---")
|
||||
for ch in chapters:
|
||||
if ch["chapter"] in (6, 7):
|
||||
bold_headings = [
|
||||
p
|
||||
for p in ch["paragraphs"]
|
||||
if p.startswith("<b>")
|
||||
and p.endswith("</b>")
|
||||
and len(re.sub(r"<[^>]+>", "", p)) < 80
|
||||
]
|
||||
print(
|
||||
f" Chapter {ch['chapter']}: {len(bold_headings)} sub-section headings found"
|
||||
)
|
||||
for h in bold_headings[:5]:
|
||||
preview = re.sub(r"<[^>]+>", "", h)
|
||||
print(f" → {preview}")
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,817 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Śrī Īśopaniṣad (1969 Edition) verses from other.html into structured JSON files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from indic_transliteration import sanscript
|
||||
from indic_transliteration.sanscript import transliterate
|
||||
|
||||
# --- ScaGoudy → IAST Unicode mapping ---
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ", # long vocalic r (U+1E5D)
|
||||
"û": "ṁl", # anusvara + l (ScaGoudy ligature glyph)
|
||||
"ÿ": "ḷ", # vocalic l (U+1E37)
|
||||
}
|
||||
|
||||
# --- Mantra number to title mapping ---
|
||||
MANTRA_TITLES = {
|
||||
0: "Invocation",
|
||||
1: "Mantra One",
|
||||
2: "Mantra Two",
|
||||
3: "Mantra Three",
|
||||
4: "Mantra Four",
|
||||
5: "Mantra Five",
|
||||
6: "Mantra Six",
|
||||
7: "Mantra Seven",
|
||||
8: "Mantra Eight",
|
||||
9: "Mantra Nine",
|
||||
10: "Mantra Ten",
|
||||
11: "Mantra Eleven",
|
||||
12: "Mantra Twelve",
|
||||
13: "Mantra Thirteen",
|
||||
14: "Mantra Fourteen",
|
||||
15: "Mantra Fifteen",
|
||||
16: "Mantra Sixteen",
|
||||
17: "Mantra Seventeen",
|
||||
18: "Mantra Eighteen",
|
||||
}
|
||||
|
||||
# --- CSS class constants ---
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
BOOKMARK_CLASS = "p13"
|
||||
VERSE_BOOKMARK_CLASS = "p49"
|
||||
VERSE_HEADING_CLASS = "p50"
|
||||
DEVANAGARI_CLASS = "p51" # RM Devanagari — SKIP
|
||||
TEXT_LABEL_CLASS = "p52"
|
||||
INVOCATION_TRANSLIT_CLASS = "p53"
|
||||
PADA_TRANSLIT_CLASS = "p5"
|
||||
SYNONYM_CLASSES = ("p54", "p59")
|
||||
TRANSLATION_LABEL_CLASS = "p55"
|
||||
TRANSLATION_TEXT_CLASS = "p56"
|
||||
PURPORT_LABEL_CLASS = "p57"
|
||||
PURPORT_CLASSES = ("p48", "p58", "p23", "p60", "p63", "p46", "p47", "p61", "p62")
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
BODY_CLASSES = ("p6", "p45")
|
||||
SKIP_CLASSES = ("p9", "p15", "p19")
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (artifacts from RTF conversion)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Extract plain text from a tag and decode ScaGoudy."""
|
||||
text = tag.get_text()
|
||||
return decode_scagoudy(text).strip()
|
||||
|
||||
|
||||
def normalize_devanagari_anusvara(text: str) -> str:
|
||||
"""Replace explicit class nasals before same-class consonants with anusvāra.
|
||||
|
||||
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
|
||||
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
|
||||
"""
|
||||
replacements = [
|
||||
# ङ् before velars (क, ख, ग, घ)
|
||||
(r"ङ्([कखगघ])", r"ं\1"),
|
||||
# ञ् before palatals (च, छ, ज, झ)
|
||||
(r"ञ्([चछजझ])", r"ं\1"),
|
||||
]
|
||||
for pattern, repl in replacements:
|
||||
text = re.sub(pattern, repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def join_virama_words(text: str) -> str:
|
||||
"""Join Devanagari words where the previous word ends with virāma (्).
|
||||
|
||||
Handles both virāma+consonant and virāma+vowel cases.
|
||||
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
|
||||
"""
|
||||
# Join virāma + space + consonant (क U+0915 through ह U+0939)
|
||||
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
|
||||
|
||||
# Join virāma + space + independent vowel
|
||||
_VOWEL_TO_MATRA = {
|
||||
"अ": "", # inherent vowel — just drop virāma and the अ
|
||||
"आ": "ा",
|
||||
"इ": "ि",
|
||||
"ई": "ी",
|
||||
"उ": "ु",
|
||||
"ऊ": "ू",
|
||||
"ऋ": "ृ",
|
||||
"ॠ": "ॄ",
|
||||
"ऌ": "ॢ",
|
||||
"ॡ": "ॣ",
|
||||
"ए": "े",
|
||||
"ऐ": "ै",
|
||||
"ओ": "ो",
|
||||
"औ": "ौ",
|
||||
}
|
||||
|
||||
def _virama_vowel_replace(m: re.Match[str]) -> str:
|
||||
vowel = m.group(1)
|
||||
return _VOWEL_TO_MATRA.get(vowel, vowel)
|
||||
|
||||
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
|
||||
return text
|
||||
|
||||
|
||||
def iast_to_devanagari(iast_text: str) -> str:
|
||||
"""Convert IAST transliteration to Devanagari script."""
|
||||
lines = iast_text.split("\n")
|
||||
result_lines = []
|
||||
for line in lines:
|
||||
# Remove hyphens between words for Devanagari
|
||||
clean = line.replace("-", "")
|
||||
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
|
||||
clean = clean.replace("ṁ", "ṃ")
|
||||
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
|
||||
deva = normalize_devanagari_anusvara(deva)
|
||||
deva = join_virama_words(deva)
|
||||
result_lines.append(deva)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def parse_synonyms(text: str) -> list:
|
||||
"""Parse synonym text into structured array.
|
||||
|
||||
Input format: "word—meaning; word—meaning; word—meaning"
|
||||
"""
|
||||
result = []
|
||||
entries = text.split(";")
|
||||
for entry in entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if "—" in entry:
|
||||
parts = entry.split("—", 1)
|
||||
elif "--" in entry:
|
||||
parts = entry.split("--", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
meaning = parts[1].strip()
|
||||
# Clean word: remove trailing/leading punctuation
|
||||
word = word.strip("., ")
|
||||
if word and meaning:
|
||||
result.append({"word": word, "meanings": [meaning]})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pair_padas(padas: list) -> str:
|
||||
"""Pair pādas into verse lines (2 pādas per line).
|
||||
|
||||
A śloka has 4 pādas displayed as 2 lines. If there's an odd number,
|
||||
the first pāda stands alone (usually a speaker attribution).
|
||||
"""
|
||||
if not padas:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
i = 0
|
||||
|
||||
# If odd number of pādas, first one is standalone
|
||||
if len(padas) % 2 == 1:
|
||||
lines.append(padas[0])
|
||||
i = 1
|
||||
|
||||
# Pair remaining pādas
|
||||
while i < len(padas) - 1:
|
||||
lines.append(padas[i] + " " + padas[i + 1])
|
||||
i += 2
|
||||
|
||||
# Handle any trailing single pāda
|
||||
if i < len(padas):
|
||||
lines.append(padas[i])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the primary CSS class of a tag."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dashed_line(text: str) -> bool:
|
||||
"""Check if text is a dashed-line separator."""
|
||||
clean = text.strip().replace("-", "").replace("—", "").strip()
|
||||
return clean == ""
|
||||
|
||||
|
||||
def parse_verse_bookmark(text: str) -> int | None:
|
||||
"""Parse a verse bookmark like 'Iso Invocation-1969' or 'Iso 1-1969'.
|
||||
|
||||
Returns mantra number (0 for Invocation, 1-18 for mantras), or None.
|
||||
"""
|
||||
text = text.strip()
|
||||
if text == "Iso Invocation-1969":
|
||||
return 0
|
||||
m = re.match(r"Iso\s+(\d+)-1969", text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_iso_boundaries(all_paragraphs: list) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices of the Iso content.
|
||||
|
||||
Start: <p class="p12"> containing "Īśopaniṣad" (ScaGoudy encoded)
|
||||
End: next <p class="p12"> after start (start of next book), or end of file
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = p.get_text().strip()
|
||||
# The ScaGoudy-encoded title contains "Éçopaniñad"
|
||||
if "opani" in text.lower() and "1969" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after Iso start = end of Iso
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Śrī Īśopaniṣad - 1969 Edition' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_section_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
|
||||
"""Find the key p13 bookmarks for Iso section boundaries.
|
||||
|
||||
Returns dict with bookmark text -> paragraph index.
|
||||
"""
|
||||
bookmarks = {}
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "Iso" in text and "1969" in text:
|
||||
bookmarks[text] = i
|
||||
return bookmarks
|
||||
|
||||
|
||||
def find_verse_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all p49 verse bookmark tags in the Iso range.
|
||||
|
||||
Returns list of (paragraph_index, mantra_number).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
mantra_num = parse_verse_bookmark(text)
|
||||
if mantra_num is not None:
|
||||
bookmarks.append((i, mantra_num))
|
||||
return bookmarks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_intro_segment(
|
||||
all_paragraphs: list, start_idx: int, end_idx: int
|
||||
) -> list[str]:
|
||||
"""Extract paragraphs for an intro segment (front pages or about the author).
|
||||
|
||||
Decodes ScaGoudy, preserves HTML formatting, skips dashed lines and empty paragraphs.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip bookmark and header classes
|
||||
if cls in (BOOKMARK_CLASS, BOOK_HEADER_CLASS, VERSE_BOOKMARK_CLASS):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
html = decode_html_content(p)
|
||||
if not html:
|
||||
continue
|
||||
|
||||
# Skip dashed-line separators
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if is_dashed_line(plain):
|
||||
continue
|
||||
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verse extraction state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
|
||||
"""Extract verses from the Iso Texts section using a state machine.
|
||||
|
||||
Returns list of verse dicts.
|
||||
"""
|
||||
verses = []
|
||||
current_verse = None
|
||||
state = "IDLE"
|
||||
|
||||
for i in range(verses_start, verses_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# --- Verse bookmark (p49) ---
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
# Finalize previous verse if any
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
text = decode_text(p)
|
||||
mantra_num = parse_verse_bookmark(text)
|
||||
if mantra_num is not None:
|
||||
title = MANTRA_TITLES.get(mantra_num, f"Mantra {mantra_num}")
|
||||
if mantra_num == 0:
|
||||
reference = "Invocation"
|
||||
else:
|
||||
reference = f"Īśo {mantra_num}"
|
||||
|
||||
current_verse = {
|
||||
"book": "iso",
|
||||
"book_title": "Śrī Īśopaniṣad",
|
||||
"mantra": mantra_num,
|
||||
"title": title,
|
||||
"reference": reference,
|
||||
"devanagari": "",
|
||||
"transliteration": "",
|
||||
"_padas": [],
|
||||
"synonyms": [],
|
||||
"translation": "",
|
||||
"purport": [],
|
||||
}
|
||||
state = "VERSE_START"
|
||||
continue
|
||||
|
||||
if not current_verse:
|
||||
continue
|
||||
|
||||
# --- Verse heading (p50) ---
|
||||
if cls == VERSE_HEADING_CLASS:
|
||||
# Just confirms the mantra title; skip
|
||||
continue
|
||||
|
||||
# --- RM Devanagari (p51) — SKIP ---
|
||||
if cls == DEVANAGARI_CLASS:
|
||||
continue
|
||||
|
||||
# --- TEXT label (p52) — skip ---
|
||||
if cls == TEXT_LABEL_CLASS:
|
||||
state = "TRANSLITERATION"
|
||||
continue
|
||||
|
||||
# --- Invocation transliteration (p53) — single <p> with <br> ---
|
||||
if cls == INVOCATION_TRANSLIT_CLASS and current_verse["mantra"] == 0:
|
||||
state = "TRANSLITERATION"
|
||||
# Split on <br> tags to get individual pādas
|
||||
# Get inner HTML and split on <br> variants
|
||||
inner_html = "".join(str(child) for child in p.children)
|
||||
# Remove <i> tags
|
||||
inner_html = re.sub(r"</?i>", "", inner_html)
|
||||
# Split on <br> or <br/> or <br />
|
||||
raw_padas = re.split(r"<br\s*/?>", inner_html)
|
||||
for pada in raw_padas:
|
||||
# Decode ScaGoudy on plain text
|
||||
pada_text = BeautifulSoup(pada, "html.parser").get_text()
|
||||
pada_text = decode_scagoudy(pada_text).strip()
|
||||
# Remove trailing period
|
||||
pada_text = pada_text.rstrip(".")
|
||||
if pada_text:
|
||||
current_verse["_padas"].append(pada_text)
|
||||
continue
|
||||
|
||||
# --- Pāda transliteration lines (p5) ---
|
||||
if cls == PADA_TRANSLIT_CLASS and state in (
|
||||
"VERSE_START",
|
||||
"TRANSLITERATION",
|
||||
):
|
||||
state = "TRANSLITERATION"
|
||||
line = decode_text(p)
|
||||
# Remove trailing period
|
||||
line = line.rstrip(".")
|
||||
# Strip leading/trailing whitespace
|
||||
line = line.strip()
|
||||
if line:
|
||||
current_verse["_padas"].append(line)
|
||||
continue
|
||||
|
||||
# --- Synonyms (p54, p59) ---
|
||||
if cls in SYNONYM_CLASSES and current_verse:
|
||||
state = "SYNONYMS"
|
||||
syn_text = decode_text(p)
|
||||
# Remove leading/trailing italic markers that might come through
|
||||
syn_text = syn_text.strip()
|
||||
parsed = parse_synonyms(syn_text)
|
||||
current_verse["synonyms"].extend(parsed)
|
||||
continue
|
||||
|
||||
# --- TRANSLATION label (p55) ---
|
||||
if cls == TRANSLATION_LABEL_CLASS and current_verse:
|
||||
state = "TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Translation text (p56) ---
|
||||
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
|
||||
html = decode_html_content(p)
|
||||
# Remove bold wrapper if present (the whole translation is bold)
|
||||
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
|
||||
# Remove trailing source references like [& Bṛhad-āraṇyaka Upaniṣad 5.1]
|
||||
# These are in span.s4 tags — they may have survived decode_html_content
|
||||
html = html.strip()
|
||||
current_verse["translation"] = html
|
||||
state = "POST_TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- PURPORT label (p57) ---
|
||||
if cls == PURPORT_LABEL_CLASS and current_verse:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
|
||||
# --- Purport paragraphs ---
|
||||
if state == "PURPORT" and current_verse and cls in PURPORT_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Skip dashed-line separators
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if not is_dashed_line(plain):
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Section heading within purport (p31) ---
|
||||
if state == "PURPORT" and current_verse and cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Body text classes that might appear in purport (p6) ---
|
||||
if state == "PURPORT" and current_verse and cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if not is_dashed_line(plain):
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# Finalize last verse
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
return verses
|
||||
|
||||
|
||||
def _finalize_verse(verse: dict):
|
||||
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
|
||||
padas = verse.get("_padas", [])
|
||||
|
||||
# Transliteration: one pāda per line
|
||||
verse["transliteration"] = "\n".join(padas)
|
||||
|
||||
# Devanagari: pair pādas into 2-line verse structure, then convert
|
||||
if padas:
|
||||
paired = pair_padas(padas)
|
||||
verse["devanagari"] = iast_to_devanagari(paired)
|
||||
|
||||
# Clean up internal field
|
||||
if "_padas" in verse:
|
||||
del verse["_padas"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON writing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write JSON with proper Unicode and formatting."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f" Written: {filepath}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "iso")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
all_paragraphs = soup.find_all("p")
|
||||
print(f"Total paragraphs in file: {len(all_paragraphs)}")
|
||||
|
||||
# --- Find Iso boundaries ---
|
||||
iso_start, iso_end = find_iso_boundaries(all_paragraphs)
|
||||
print(f"Iso content: paragraphs {iso_start} to {iso_end}")
|
||||
|
||||
# --- Find section bookmarks (p13) ---
|
||||
section_bookmarks = find_section_bookmarks(all_paragraphs, iso_start, iso_end)
|
||||
print(f"\nSection bookmarks found:")
|
||||
for bm_text, bm_idx in sorted(section_bookmarks.items(), key=lambda x: x[1]):
|
||||
print(f" [{bm_idx}] {bm_text}")
|
||||
|
||||
# Key bookmark indices
|
||||
front_pages_idx = section_bookmarks.get("Iso back cover & front pages-1969")
|
||||
introduction_idx = section_bookmarks.get("Iso Introduction-1969")
|
||||
texts_idx = section_bookmarks.get("Iso Texts-1969")
|
||||
about_idx = section_bookmarks.get("Iso About the author-1969")
|
||||
glossary_idx = section_bookmarks.get("Iso Glossary-1969")
|
||||
|
||||
if texts_idx is None:
|
||||
print("ERROR: Could not find 'Iso Texts-1969' bookmark!")
|
||||
sys.exit(1)
|
||||
|
||||
# Use iso_end as fallback for glossary boundary
|
||||
end_boundary = glossary_idx if glossary_idx is not None else iso_end
|
||||
|
||||
# --- Extract intro segments ---
|
||||
print("\n--- Extracting Intro Segments ---")
|
||||
|
||||
intro_segments = []
|
||||
|
||||
# 1. Front pages segment (up to the Introduction bookmark, or texts if no intro)
|
||||
front_end = introduction_idx if introduction_idx is not None else texts_idx
|
||||
if front_pages_idx is not None and front_end is not None:
|
||||
front_paras = extract_intro_segment(all_paragraphs, front_pages_idx, front_end)
|
||||
front_segment = {
|
||||
"slug": "front-pages",
|
||||
"title": "Back Cover & Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "iso",
|
||||
"order": 1,
|
||||
"paragraphs": front_paras,
|
||||
}
|
||||
intro_segments.append(front_segment)
|
||||
print(f" Front Pages: {len(front_paras)} paragraphs")
|
||||
|
||||
# 2. Introduction (Teachings of the Vedas)
|
||||
if introduction_idx is not None and texts_idx is not None:
|
||||
intro_paras = extract_intro_segment(all_paragraphs, introduction_idx, texts_idx)
|
||||
intro_segment = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction (Teachings of the Vedas)",
|
||||
"type": "intro-segment",
|
||||
"book": "iso",
|
||||
"order": 2,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
intro_segments.append(intro_segment)
|
||||
print(f" Introduction: {len(intro_paras)} paragraphs")
|
||||
|
||||
# 3. About the Author segment
|
||||
if about_idx is not None:
|
||||
about_paras = extract_intro_segment(all_paragraphs, about_idx, end_boundary)
|
||||
about_segment = {
|
||||
"slug": "about-the-author",
|
||||
"title": "About the Author",
|
||||
"type": "intro-segment",
|
||||
"book": "iso",
|
||||
"order": 3,
|
||||
"paragraphs": about_paras,
|
||||
}
|
||||
intro_segments.append(about_segment)
|
||||
print(f" About the Author: {len(about_paras)} paragraphs")
|
||||
|
||||
# --- Extract verses ---
|
||||
print("\n--- Extracting Verses ---")
|
||||
|
||||
# Verse section runs from texts_idx to about_idx (or end_boundary)
|
||||
verse_end = about_idx if about_idx is not None else end_boundary
|
||||
verses = extract_verses(all_paragraphs, texts_idx, verse_end)
|
||||
print(f"Extracted {len(verses)} verses (expected 19: Invocation + Mantras 1-18)")
|
||||
|
||||
# --- Write intro.json ---
|
||||
mantras_listing = []
|
||||
for num in range(0, 19):
|
||||
title = MANTRA_TITLES[num]
|
||||
if num == 0:
|
||||
slug = "invocation"
|
||||
else:
|
||||
slug = str(num).zfill(2)
|
||||
mantras_listing.append({"number": num, "title": title, "slug": slug})
|
||||
|
||||
intro_meta = {
|
||||
"book": "iso",
|
||||
"title": "Śrī Īśopaniṣad",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1969,
|
||||
"description": "The foremost of the 108 Upaniṣads, containing the essence of all Vedic knowledge.",
|
||||
"totalMantras": 18,
|
||||
"mantras": mantras_listing,
|
||||
}
|
||||
write_json(intro_meta, os.path.join(output_base, "intro.json"))
|
||||
|
||||
# --- Write intro segments ---
|
||||
print("\n--- Writing Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
|
||||
# --- Write verse JSON files ---
|
||||
print("\n--- Writing Verse Files ---")
|
||||
for verse in verses:
|
||||
mantra_num = verse["mantra"]
|
||||
if mantra_num == 0:
|
||||
filename = "invocation.json"
|
||||
else:
|
||||
filename = f"{str(mantra_num).zfill(2)}.json"
|
||||
filepath = os.path.join(output_base, "verses", filename)
|
||||
write_json(verse, filepath)
|
||||
|
||||
# --- Summary ---
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Book: Śrī Īśopaniṣad (1969 Edition)")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Verses extracted: {len(verses)}")
|
||||
print(
|
||||
f" Invocation: {'Yes' if any(v['mantra'] == 0 for v in verses) else 'No'}"
|
||||
)
|
||||
mantras_found = sorted([v["mantra"] for v in verses if v["mantra"] > 0])
|
||||
print(
|
||||
f" Mantras: {mantras_found[0]}-{mantras_found[-1]} ({len(mantras_found)} total)"
|
||||
if mantras_found
|
||||
else " Mantras: None"
|
||||
)
|
||||
|
||||
total_purport_paras = sum(len(v["purport"]) for v in verses)
|
||||
total_synonyms = sum(len(v["synonyms"]) for v in verses)
|
||||
print(f" Total purport ¶s: {total_purport_paras}")
|
||||
print(f" Total synonyms: {total_synonyms}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Quality checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all verses have expected fields
|
||||
issues = []
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
if not verse["transliteration"]:
|
||||
issues.append(f" {ref}: missing transliteration")
|
||||
if not verse["devanagari"]:
|
||||
issues.append(f" {ref}: missing devanagari")
|
||||
if not verse["synonyms"]:
|
||||
issues.append(f" {ref}: missing synonyms")
|
||||
if not verse["translation"]:
|
||||
issues.append(f" {ref}: missing translation")
|
||||
if not verse["purport"]:
|
||||
issues.append(f" {ref}: missing purport")
|
||||
|
||||
if issues:
|
||||
print(" ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(issue)
|
||||
else:
|
||||
print(
|
||||
" All verses have transliteration, devanagari, synonyms, translation, and purport ✓"
|
||||
)
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Invocation ---")
|
||||
inv = [v for v in verses if v["mantra"] == 0]
|
||||
if inv:
|
||||
v = inv[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Mantra One ---")
|
||||
m1 = [v for v in verses if v["mantra"] == 1]
|
||||
if m1:
|
||||
v = m1[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Mantra Eighteen (last) ---")
|
||||
m18 = [v for v in verses if v["mantra"] == 18]
|
||||
if m18:
|
||||
v = m18[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,644 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Kṛṣṇa Consciousness: The Matchless Gift (1974) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 7 chapters.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/mg/intro.json — Book metadata
|
||||
lib/jsons/books/mg/intro/*.json — Intro segments
|
||||
lib/jsons/books/mg/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/mg_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for MG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6", "p78", "p30"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered) — BOTH p53 AND p24
|
||||
VERSE_PADA_CLASSES = frozenset(["p53", "p24"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Book list header/footer
|
||||
BOOK_LIST_CLASSES = frozenset(["p76", "p77"])
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p70", "p79"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_mg_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the MG content.
|
||||
|
||||
Start: <p class="p12"> containing "Matchless Gift" and "1974"
|
||||
End: next <p class="p12"> after start, or <p class="p27"> containing "APPENDIX"
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Matchless Gift" in text and "1974" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after MG start = end of MG
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Kṛṣṇa Consciousness: The Matchless Gift (1974)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for APPENDIXES/APPENDIX as end boundary (p27 with bold text)
|
||||
for i in range(start_idx + 1, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
text = p.get_text().strip().upper()
|
||||
if text.startswith("APPENDIX") or text.startswith("APPENDIXES"):
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all MG chapter bookmark tags (p13) in the MG range.
|
||||
|
||||
Returns list of (paragraph_index, chapter_number, chapter_title).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
# Use re.DOTALL to handle titles that span multiple lines
|
||||
m = re.match(r"MG\s+(\d+)\s*:\s*(.+)", text, re.DOTALL)
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
# Clean the title: replace newlines with spaces, normalize whitespace
|
||||
ch_title = re.sub(r"\s+", " ", m.group(2)).strip()
|
||||
# Capitalize "love" in "Learning to love" -> "Learning to Love"
|
||||
if ch_title == "Learning to love":
|
||||
ch_title = "Learning to Love"
|
||||
bookmarks.append((i, ch_num, ch_title))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the MG content.
|
||||
Groups consecutive p53/p24 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53 and p24) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Book list classes (p76, p77) — include in front-pages
|
||||
if cls in BOOK_LIST_CLASSES:
|
||||
if is_front_pages:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6, p78, p30)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_mg(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Kṛṣṇa Consciousness: The Matchless Gift (1974) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find MG boundaries
|
||||
mg_start, mg_end = find_mg_boundaries(all_paragraphs)
|
||||
print(f" MG content: paragraphs {mg_start} to {mg_end}")
|
||||
print(f" Total paragraphs in range: {mg_end - mg_start}")
|
||||
|
||||
# Step 2: Find all chapter bookmarks
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, mg_start, mg_end)
|
||||
print(f" Found {len(chapter_bookmarks)} MG chapter bookmarks")
|
||||
for pos, ch_num, ch_title in chapter_bookmarks:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (mg_start) to first chapter bookmark (MG 1)
|
||||
intro_segments = []
|
||||
|
||||
if chapter_bookmarks:
|
||||
front_pages_end = chapter_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = mg_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, mg_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "mg",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or mg_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = mg_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "mg",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "mg")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Kṛṣṇa Consciousness: The Matchless Gift (1974)...\n")
|
||||
result = extract_mg(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "mg",
|
||||
"title": "Kṛṣṇa Consciousness: The Matchless Gift",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1974,
|
||||
"description": "A profound introduction to the science of Kṛṣṇa consciousness and the matchless gift of love of God.",
|
||||
"totalChapters": 7,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 7 chapters present
|
||||
expected_chapters = set(range(1, 8))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 7 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,803 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract The Nectar of Devotion (1970 Edition) from other.html.
|
||||
|
||||
The book has 3 intro segments (Front Flap, Preface, Introduction),
|
||||
51 chapters organized in 4 parts, and a Concluding Words section.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/nod/intro.json — Book metadata
|
||||
lib/jsons/books/nod/intro/*.json — Intro segments
|
||||
lib/jsons/books/nod/chapters/*.json — Chapters + concluding words
|
||||
|
||||
Usage:
|
||||
python3 scripts/nod_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for NoD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Bookmark class — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Body paragraph classes
|
||||
BODY_CLASSES = frozenset(["p6", "p35", "p30"])
|
||||
|
||||
# Section heading (bold) — "Chapter One", "Preface", etc.
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
|
||||
# Chapter title (bold) — "Characteristics of Pure Devotional Service", etc.
|
||||
CHAPTER_TITLE_CLASS = "p27"
|
||||
|
||||
# Part header class — "PART ONE", "PART TWO", etc.
|
||||
PART_HEADER_CLASS = "p34"
|
||||
|
||||
# Sub-section heading (bold) within chapters
|
||||
SUB_HEADING_CLASS = "p36"
|
||||
|
||||
# Italic sub-headings
|
||||
ITALIC_SUB_HEADING_CLASSES = frozenset(["p37", "p38"])
|
||||
|
||||
# Title page / copyright text classes (in intro area)
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
COPYRIGHT_CLASS = "p32"
|
||||
|
||||
# Sanskrit verse (italic with br tags)
|
||||
SANSKRIT_VERSE_CLASS = "p33"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p15"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INTRO_SEGMENTS = [
|
||||
{
|
||||
"slug": "front-flap",
|
||||
"title": "Front Flap",
|
||||
"order": 1,
|
||||
"bookmark_pattern": "NoD-1970: Front Flap",
|
||||
},
|
||||
{
|
||||
"slug": "preface",
|
||||
"title": "Preface",
|
||||
"order": 2,
|
||||
"bookmark_pattern": "NoD-1970: Preface",
|
||||
},
|
||||
{
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"order": 3,
|
||||
"bookmark_pattern": "NoD-1970: Introduction",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_nod_boundaries(all_paragraphs: list) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices of the NoD content.
|
||||
|
||||
Start: <p class="p12"> containing "Nectar of Devotion"
|
||||
End: next <p class="p12"> after start, or end of file
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Nectar of Devotion" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after NoD start = end of NoD
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'The Nectar of Devotion' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all p13 bookmark tags in the NoD range.
|
||||
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "NoD" in text:
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str) -> tuple[int, str] | None:
|
||||
"""Parse a chapter bookmark like 'NoD 1-1970: Characteristics of Pure Devotional Service'.
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"NoD\s+(\d+)-1970:\s*(.+)", text.strip())
|
||||
if m:
|
||||
return int(m.group(1)), m.group(2).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str) -> str | None:
|
||||
"""Parse an intro bookmark like 'NoD-1970: Preface'.
|
||||
|
||||
Returns the segment name or None if not an intro bookmark.
|
||||
"""
|
||||
m = re.match(r"NoD-1970:\s*(.+)", text.strip())
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_intro: bool = False,
|
||||
) -> list[str]:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the NoD content.
|
||||
"""
|
||||
result: list[str] = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Part headers — include as bold paragraph
|
||||
if cls == PART_HEADER_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section heading (p31) — include as paragraph
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Chapter title (p27) — include as bold paragraph
|
||||
if cls == CHAPTER_TITLE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Sub-section headings within chapters (p36, bold)
|
||||
if cls == SUB_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Italic sub-headings (p37, p38)
|
||||
if cls in ITALIC_SUB_HEADING_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page / copyright text (in intro area)
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Copyright text
|
||||
if cls == COPYRIGHT_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Sanskrit verse (italic with br tags) — format as centered italic div
|
||||
# Strip all inner <i>/<\/i> tags before re-wrapping to avoid nesting
|
||||
if cls == SANSKRIT_VERSE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
result.append(f'<div style="text-align:center"><i>{stripped}</i></div>')
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6, p35, p30)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_nod(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Nectar of Devotion from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"concluding_words": concluding words dict
|
||||
"parts": list of part assignment dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find NoD boundaries
|
||||
nod_start, nod_end = find_nod_boundaries(all_paragraphs)
|
||||
print(f" NoD content: paragraphs {nod_start} to {nod_end}")
|
||||
print(f" Total paragraphs in range: {nod_end - nod_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, nod_start, nod_end)
|
||||
print(f" Found {len(bookmarks)} NoD bookmarks")
|
||||
|
||||
# Step 3: Categorize bookmarks into intro vs chapter
|
||||
intro_bookmarks = [] # (para_idx, segment_name)
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
else:
|
||||
intro_name = parse_intro_bookmark(text)
|
||||
if intro_name:
|
||||
intro_bookmarks.append((pos, intro_name))
|
||||
|
||||
print(f" Intro bookmarks: {len(intro_bookmarks)}")
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
|
||||
# Step 4: Track PART assignments by scanning for p34 tags
|
||||
# We need to know which chapters belong to which part.
|
||||
part_assignments = {} # chapter_num -> part_num
|
||||
parts_data = [] # list of {number, title, chapters}
|
||||
|
||||
# Walk through all paragraphs in NoD range and track parts
|
||||
# We process this before extracting chapters so we know part assignments
|
||||
part_positions = [] # (para_idx, part_title)
|
||||
for i in range(nod_start, nod_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == PART_HEADER_CLASS:
|
||||
text = decode_text(p)
|
||||
if text:
|
||||
part_positions.append((i, text))
|
||||
|
||||
print(f" Part headers found: {len(part_positions)}")
|
||||
for pos, title in part_positions:
|
||||
print(f" Line ~{pos}: {title}")
|
||||
|
||||
# Assign parts to chapters based on position
|
||||
# A chapter belongs to the most recent PART header before its bookmark
|
||||
part_number_map = {}
|
||||
for pos, title in part_positions:
|
||||
# Extract part number from title like "PART ONE", "PART TWO", etc.
|
||||
part_num = None
|
||||
title_upper = title.upper()
|
||||
if "ONE" in title_upper or "I" == title_upper.replace("PART", "").strip():
|
||||
part_num = 1
|
||||
elif "TWO" in title_upper or "II" == title_upper.replace("PART", "").strip():
|
||||
part_num = 2
|
||||
elif "THREE" in title_upper or "III" == title_upper.replace("PART", "").strip():
|
||||
part_num = 3
|
||||
elif "FOUR" in title_upper or "IV" == title_upper.replace("PART", "").strip():
|
||||
part_num = 4
|
||||
if part_num:
|
||||
part_number_map[pos] = part_num
|
||||
|
||||
# Now assign each chapter to a part
|
||||
sorted_part_positions = sorted(part_number_map.keys())
|
||||
for ch_pos, ch_num, ch_title in chapter_bookmarks:
|
||||
# Find the most recent PART header before this chapter's bookmark
|
||||
assigned_part = None
|
||||
for pp in sorted_part_positions:
|
||||
if pp < ch_pos:
|
||||
assigned_part = part_number_map[pp]
|
||||
else:
|
||||
break
|
||||
if assigned_part:
|
||||
part_assignments[ch_num] = assigned_part
|
||||
|
||||
# Build parts_data
|
||||
for part_num in sorted(set(part_assignments.values())):
|
||||
chs = sorted([ch for ch, pn in part_assignments.items() if pn == part_num])
|
||||
part_title = f"Part {'One' if part_num == 1 else 'Two' if part_num == 2 else 'Three' if part_num == 3 else 'Four'}"
|
||||
parts_data.append(
|
||||
{
|
||||
"number": part_num,
|
||||
"title": part_title,
|
||||
"chapters": chs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 5: Extract intro segments
|
||||
intro_segments = []
|
||||
for seg_def in INTRO_SEGMENTS:
|
||||
# Find matching bookmark
|
||||
bookmark_pos = None
|
||||
for pos, name in intro_bookmarks:
|
||||
if (
|
||||
seg_def["bookmark_pattern"].endswith(name)
|
||||
or name == seg_def["bookmark_pattern"].split(": ", 1)[-1]
|
||||
):
|
||||
bookmark_pos = pos
|
||||
break
|
||||
|
||||
if bookmark_pos is None:
|
||||
print(f" WARNING: Could not find bookmark for '{seg_def['slug']}'")
|
||||
continue
|
||||
|
||||
# Content starts after the bookmark
|
||||
content_start = bookmark_pos + 1
|
||||
|
||||
# Content ends at the next bookmark (intro or chapter) or PART header
|
||||
content_end = nod_end
|
||||
all_section_starts = sorted(
|
||||
[pos for pos, _ in intro_bookmarks]
|
||||
+ [pos for pos, _, _ in chapter_bookmarks]
|
||||
)
|
||||
for s in all_section_starts:
|
||||
if s > bookmark_pos:
|
||||
content_end = s
|
||||
break
|
||||
|
||||
paragraphs = extract_paragraphs(
|
||||
all_paragraphs, content_start, content_end, is_intro=True
|
||||
)
|
||||
|
||||
seg = {
|
||||
"slug": seg_def["slug"],
|
||||
"title": seg_def["title"],
|
||||
"type": "intro-segment",
|
||||
"book": "nod",
|
||||
"order": seg_def["order"],
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
intro_segments.append(seg)
|
||||
|
||||
# Step 6: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or "Concluding Words" p27 or nod_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
# Last chapter (51) — content ends at "Concluding Words" p27 heading
|
||||
# or at nod_end. We need to find where "Concluding Words" starts.
|
||||
# Look for p27 containing "Concluding Words" after the last chapter bookmark.
|
||||
concluding_pos = None
|
||||
for i in range(ch_pos + 1, nod_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_TITLE_CLASS:
|
||||
text = decode_text(p)
|
||||
if "Concluding Words" in text:
|
||||
concluding_pos = i
|
||||
break
|
||||
|
||||
if concluding_pos:
|
||||
content_end = concluding_pos
|
||||
else:
|
||||
content_end = nod_end
|
||||
|
||||
# For the PART header: if a PART header appears right before this
|
||||
# chapter's bookmark, include it in this chapter's content range.
|
||||
# The PART header might be 1-3 paragraphs before the bookmark (with
|
||||
# possible p15 empty lines in between).
|
||||
actual_start = content_start
|
||||
for pp in sorted_part_positions:
|
||||
# Check if this PART header is between the previous section end and
|
||||
# this chapter's bookmark
|
||||
if pp < ch_pos and pp >= content_start - 5:
|
||||
# Check that there's no other bookmark between pp and ch_pos
|
||||
has_other_bookmark = False
|
||||
for bpos, _ in bookmarks:
|
||||
if bpos > pp and bpos < ch_pos:
|
||||
has_other_bookmark = True
|
||||
break
|
||||
if not has_other_bookmark:
|
||||
actual_start = pp
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, actual_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "nod",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 7: Extract "Concluding Words"
|
||||
concluding_words = None
|
||||
# Find the "Concluding Words" p27 heading after chapter 51
|
||||
last_ch_pos = chapter_bookmarks[-1][0] if chapter_bookmarks else nod_start
|
||||
for i in range(last_ch_pos, nod_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_TITLE_CLASS:
|
||||
text = decode_text(p)
|
||||
if "Concluding Words" in text:
|
||||
# Extract from this heading to nod_end
|
||||
paragraphs = extract_paragraphs(all_paragraphs, i, nod_end)
|
||||
concluding_words = {
|
||||
"book": "nod",
|
||||
"chapter": None,
|
||||
"title": "Concluding Words",
|
||||
"type": "conclusion",
|
||||
"slug": "concluding-words",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
break
|
||||
|
||||
# Step 8: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"concluding_words": concluding_words,
|
||||
"parts": parts_data,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "nod")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting The Nectar of Devotion (1970 Edition)...\n")
|
||||
result = extract_nod(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
concluding_words = result["concluding_words"]
|
||||
parts = result["parts"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "nod",
|
||||
"title": "The Nectar of Devotion",
|
||||
"subtitle": "A Summary Study of Śrīla Rūpa Gosvāmī's Bhakti-rasāmṛta-sindhu",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1970,
|
||||
"description": "The complete science of bhakti-yoga, or devotional service to Lord Kṛṣṇa.",
|
||||
"totalChapters": 51,
|
||||
"parts": parts,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
part_info = ""
|
||||
for p in parts:
|
||||
if ch["chapter"] in p["chapters"]:
|
||||
part_info = f" [Part {p['number']}]"
|
||||
break
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}{part_info}"
|
||||
)
|
||||
|
||||
# --- Write concluding words ---
|
||||
if concluding_words:
|
||||
filepath = os.path.join(output_base, "chapters", "concluding-words.json")
|
||||
write_json(concluding_words, filepath)
|
||||
print(
|
||||
f"\n Concluding Words: {len(concluding_words['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_concluding_paras = (
|
||||
len(concluding_words["paragraphs"]) if concluding_words else 0
|
||||
)
|
||||
total_paras = total_intro_paras + total_chapter_paras + total_concluding_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Concluding Words: {'Yes' if concluding_words else 'No'}")
|
||||
print(f" Parts: {len(parts)}")
|
||||
for p in parts:
|
||||
print(
|
||||
f" Part {p['number']} ({p['title']}): chapters {p['chapters'][0]}-{p['chapters'][-1]} ({len(p['chapters'])} chapters)"
|
||||
)
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
print(f" Concluding: {total_concluding_paras}")
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:4]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
print(f"\n--- Sample: Chapter {chapters[-1]['chapter']} (last) ---")
|
||||
if chapters:
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if concluding_words:
|
||||
print(f"\n--- Sample: Concluding Words ---")
|
||||
for i, p in enumerate(concluding_words["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 51 chapters present
|
||||
expected_chapters = set(range(1, 52))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 51 chapters present")
|
||||
|
||||
# Check all 3 intro segments present
|
||||
expected_intros = {"front-flap", "preface", "introduction"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
else:
|
||||
print(f" ✅ All 3 intro segments present")
|
||||
|
||||
# Check "Thus ends" markers in chapters
|
||||
chapters_with_end = 0
|
||||
for ch in chapters:
|
||||
has_end = any("Thus end" in p for p in ch["paragraphs"])
|
||||
if has_end:
|
||||
chapters_with_end += 1
|
||||
print(f" ℹ {chapters_with_end}/{len(chapters)} chapters have 'Thus ends' markers")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters that should have been decoded
|
||||
# Note: ñ is excluded because it's a valid IAST output character (ï→ñ mapping)
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
# Only flag if the encoded chars appear outside HTML tags
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
# Many of these are legitimate uses (e.g., German loanwords)
|
||||
# but in this context they almost always indicate ScaGoudy encoding
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check part assignments
|
||||
all_part_chapters = set()
|
||||
for p in parts:
|
||||
all_part_chapters.update(p["chapters"])
|
||||
unassigned = [
|
||||
ch["chapter"] for ch in chapters if ch["chapter"] not in all_part_chapters
|
||||
]
|
||||
if unassigned:
|
||||
print(f" ⚠ Chapters not assigned to any part: {unassigned}")
|
||||
else:
|
||||
print(f" ✅ All chapters assigned to parts")
|
||||
|
||||
# Verify ScaGoudy decoding with a spot check on known text
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
# Chapter 1 should contain "Kṛṣṇa" (encoded as "Kåñëa")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:5])
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
|
||||
elif "Kåñëa" in ch1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 5 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
# Check for "Śrīmad-Bhāgavatam" (encoded as "Çrémad-Bhägavatam")
|
||||
if "Śrīmad-Bhāgavatam" in ch1_text:
|
||||
print(f" ✅ 'Śrīmad-Bhāgavatam' correctly decoded")
|
||||
elif "Çrémad-Bhägavatam" in ch1_text:
|
||||
print(f" ⚠ ScaGoudy decoding incomplete for 'Çrémad-Bhägavatam'")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,859 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract The Nectar of Instruction (1976 Edition) verses from other.html into structured JSON files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from indic_transliteration import sanscript
|
||||
from indic_transliteration.sanscript import transliterate
|
||||
|
||||
# --- ScaGoudy → IAST Unicode mapping ---
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ", # long vocalic r (U+1E5D)
|
||||
"û": "ṁl", # anusvara + l (ScaGoudy ligature glyph)
|
||||
"ÿ": "ḷ", # vocalic l (U+1E37)
|
||||
}
|
||||
|
||||
# --- Text number to title mapping ---
|
||||
TEXT_TITLES = {
|
||||
1: "Text One",
|
||||
2: "Text Two",
|
||||
3: "Text Three",
|
||||
4: "Text Four",
|
||||
5: "Text Five",
|
||||
6: "Text Six",
|
||||
7: "Text Seven",
|
||||
8: "Text Eight",
|
||||
9: "Text Nine",
|
||||
10: "Text Ten",
|
||||
11: "Text Eleven",
|
||||
}
|
||||
|
||||
# --- CSS class constants ---
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
SECTION_BOOKMARK_CLASS = "p64" # NoI section bookmarks (Front pages, Preface)
|
||||
VERSE_BOOKMARK_CLASS = "p49" # NoI verse bookmarks (NoI: verse N) AND appendixes
|
||||
VERSE_HEADING_CLASS = "p50"
|
||||
DEVANAGARI_CLASS = "p51" # RM Devanagari — SKIP
|
||||
TEXT_LABEL_CLASS = "p52"
|
||||
PADA_TRANSLIT_CLASS = "p5"
|
||||
SYNONYM_CLASS = "p54"
|
||||
TRANSLATION_LABEL_CLASS = "p55"
|
||||
TRANSLATION_TEXT_CLASS = "p56"
|
||||
PURPORT_LABEL_CLASS = "p57"
|
||||
PURPORT_BODY_CLASS = "p48"
|
||||
QUOTED_VERSE_CLASSES = (
|
||||
"p33",
|
||||
"p53",
|
||||
"p66",
|
||||
) # Quoted Sanskrit/Bengali verses in purport
|
||||
BODY_TEXT_CLASS = "p44" # Verse translations within purport
|
||||
MISC_TEXT_CLASS = "p67" # Miscellaneous text (1 occurrence in NoI)
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
BODY_CLASSES = ("p6", "p45")
|
||||
SKIP_CLASSES = ("p9", "p15", "p19", "p25", "p32", "p37", "p65")
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (artifacts from RTF conversion)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Extract plain text from a tag and decode ScaGoudy."""
|
||||
text = tag.get_text()
|
||||
return decode_scagoudy(text).strip()
|
||||
|
||||
|
||||
def normalize_devanagari_anusvara(text: str) -> str:
|
||||
"""Replace explicit class nasals before same-class consonants with anusvāra.
|
||||
|
||||
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
|
||||
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
|
||||
"""
|
||||
replacements = [
|
||||
# ङ् before velars (क, ख, ग, घ)
|
||||
(r"ङ्([कखगघ])", r"ं\1"),
|
||||
# ञ् before palatals (च, छ, ज, झ)
|
||||
(r"ञ्([चछजझ])", r"ं\1"),
|
||||
]
|
||||
for pattern, repl in replacements:
|
||||
text = re.sub(pattern, repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def join_virama_words(text: str) -> str:
|
||||
"""Join Devanagari words where the previous word ends with virāma (्).
|
||||
|
||||
Handles both virāma+consonant and virāma+vowel cases.
|
||||
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
|
||||
"""
|
||||
# Join virāma + space + consonant (क U+0915 through ह U+0939)
|
||||
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
|
||||
|
||||
# Join virāma + space + independent vowel
|
||||
_VOWEL_TO_MATRA = {
|
||||
"अ": "", # inherent vowel — just drop virāma and the अ
|
||||
"आ": "ा",
|
||||
"इ": "ि",
|
||||
"ई": "ी",
|
||||
"उ": "ु",
|
||||
"ऊ": "ू",
|
||||
"ऋ": "ृ",
|
||||
"ॠ": "ॄ",
|
||||
"ऌ": "ॢ",
|
||||
"ॡ": "ॣ",
|
||||
"ए": "े",
|
||||
"ऐ": "ै",
|
||||
"ओ": "ो",
|
||||
"औ": "ौ",
|
||||
}
|
||||
|
||||
def _virama_vowel_replace(m: re.Match[str]) -> str:
|
||||
vowel = m.group(1)
|
||||
return _VOWEL_TO_MATRA.get(vowel, vowel)
|
||||
|
||||
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
|
||||
return text
|
||||
|
||||
|
||||
def iast_to_devanagari(iast_text: str) -> str:
|
||||
"""Convert IAST transliteration to Devanagari script."""
|
||||
lines = iast_text.split("\n")
|
||||
result_lines = []
|
||||
for line in lines:
|
||||
# Remove hyphens between words for Devanagari
|
||||
clean = line.replace("-", "")
|
||||
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
|
||||
clean = clean.replace("ṁ", "ṃ")
|
||||
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
|
||||
deva = normalize_devanagari_anusvara(deva)
|
||||
deva = join_virama_words(deva)
|
||||
result_lines.append(deva)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def parse_synonyms(text: str) -> list:
|
||||
"""Parse synonym text into structured array.
|
||||
|
||||
Input format: "word—meaning; word—meaning; word—meaning"
|
||||
"""
|
||||
result = []
|
||||
entries = text.split(";")
|
||||
for entry in entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if "—" in entry:
|
||||
parts = entry.split("—", 1)
|
||||
elif "--" in entry:
|
||||
parts = entry.split("--", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
meaning = parts[1].strip()
|
||||
# Clean word: remove trailing/leading punctuation
|
||||
word = word.strip("., ")
|
||||
if word and meaning:
|
||||
result.append({"word": word, "meanings": [meaning]})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pair_padas(padas: list) -> str:
|
||||
"""Pair pādas into verse lines (2 pādas per line).
|
||||
|
||||
A śloka has 4 pādas displayed as 2 lines. If there's an odd number,
|
||||
the first pāda stands alone (usually a speaker attribution).
|
||||
"""
|
||||
if not padas:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
i = 0
|
||||
|
||||
# If odd number of pādas, first one is standalone
|
||||
if len(padas) % 2 == 1:
|
||||
lines.append(padas[0])
|
||||
i = 1
|
||||
|
||||
# Pair remaining pādas
|
||||
while i < len(padas) - 1:
|
||||
lines.append(padas[i] + " " + padas[i + 1])
|
||||
i += 2
|
||||
|
||||
# Handle any trailing single pāda
|
||||
if i < len(padas):
|
||||
lines.append(padas[i])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the primary CSS class of a tag."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dashed_line(text: str) -> bool:
|
||||
"""Check if text is a dashed-line separator."""
|
||||
clean = text.strip().replace("-", "").replace("—", "").strip()
|
||||
return clean == ""
|
||||
|
||||
|
||||
def parse_verse_bookmark(text: str) -> int | None:
|
||||
"""Parse a verse bookmark like 'NoI: verse 1' through 'NoI: verse 11'.
|
||||
|
||||
Returns text number (1-11), or None if not a verse bookmark.
|
||||
"""
|
||||
text = text.strip()
|
||||
m = re.match(r"NoI:\s*verse\s+(\d+)", text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_noi_boundaries(all_paragraphs: list) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices of the NoI content.
|
||||
|
||||
Start: <p class="p12"> containing "Nectar of Instruction" and "1976"
|
||||
End: next <p class="p12"> after start (start of next book), or end of file
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = p.get_text().strip()
|
||||
if "Nectar of Instruction" in text and "1976" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after NoI start = end of NoI
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'The Nectar of Instruction - 1976 Edition' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_section_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
|
||||
"""Find the key p64 bookmarks for NoI section boundaries.
|
||||
|
||||
Returns dict with bookmark text -> paragraph index.
|
||||
"""
|
||||
bookmarks = {}
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == SECTION_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "NoI" in text:
|
||||
bookmarks[text] = i
|
||||
return bookmarks
|
||||
|
||||
|
||||
def find_verse_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all p49 verse bookmark tags in the NoI range.
|
||||
|
||||
Returns list of (paragraph_index, text_number) for actual verse bookmarks.
|
||||
Also returns appendixes bookmark index separately.
|
||||
"""
|
||||
verse_bookmarks = []
|
||||
appendixes_idx = None
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
text_num = parse_verse_bookmark(text)
|
||||
if text_num is not None:
|
||||
verse_bookmarks.append((i, text_num))
|
||||
elif text.strip() == "NoI Appendixes":
|
||||
appendixes_idx = i
|
||||
return verse_bookmarks, appendixes_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_intro_segment(
|
||||
all_paragraphs: list, start_idx: int, end_idx: int
|
||||
) -> list[str]:
|
||||
"""Extract paragraphs for an intro segment (front pages, preface, or appendixes).
|
||||
|
||||
Decodes ScaGoudy, preserves HTML formatting, skips dashed lines and empty paragraphs.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip bookmark and header classes
|
||||
if cls in (SECTION_BOOKMARK_CLASS, BOOK_HEADER_CLASS, VERSE_BOOKMARK_CLASS):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
html = decode_html_content(p)
|
||||
if not html:
|
||||
continue
|
||||
|
||||
# Skip dashed-line separators
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if is_dashed_line(plain):
|
||||
continue
|
||||
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verse extraction state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
|
||||
"""Extract verses from the NoI section using a state machine.
|
||||
|
||||
Returns list of verse dicts.
|
||||
"""
|
||||
verses = []
|
||||
current_verse = None
|
||||
state = "IDLE"
|
||||
|
||||
for i in range(verses_start, verses_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# --- Verse bookmark (p49) ---
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
# Finalize previous verse if any
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
text = decode_text(p)
|
||||
text_num = parse_verse_bookmark(text)
|
||||
if text_num is not None:
|
||||
title = TEXT_TITLES.get(text_num, f"Text {text_num}")
|
||||
reference = f"NoI {text_num}"
|
||||
|
||||
current_verse = {
|
||||
"book": "noi",
|
||||
"book_title": "The Nectar of Instruction",
|
||||
"text": text_num,
|
||||
"title": title,
|
||||
"reference": reference,
|
||||
"devanagari": "",
|
||||
"transliteration": "",
|
||||
"_padas": [],
|
||||
"synonyms": [],
|
||||
"translation": "",
|
||||
"purport": [],
|
||||
}
|
||||
state = "VERSE_START"
|
||||
else:
|
||||
# Non-verse bookmark (e.g., "NoI Appendixes") — stop processing
|
||||
current_verse = None
|
||||
state = "IDLE"
|
||||
continue
|
||||
|
||||
if not current_verse:
|
||||
continue
|
||||
|
||||
# --- Verse heading (p50) ---
|
||||
if cls == VERSE_HEADING_CLASS:
|
||||
# Just confirms the text title; skip
|
||||
continue
|
||||
|
||||
# --- RM Devanagari (p51) — SKIP ---
|
||||
if cls == DEVANAGARI_CLASS:
|
||||
continue
|
||||
|
||||
# --- TEXT label (p52) — skip ---
|
||||
if cls == TEXT_LABEL_CLASS:
|
||||
state = "TRANSLITERATION"
|
||||
continue
|
||||
|
||||
# --- Pāda transliteration lines (p5) ---
|
||||
if cls == PADA_TRANSLIT_CLASS and state in (
|
||||
"VERSE_START",
|
||||
"TRANSLITERATION",
|
||||
):
|
||||
state = "TRANSLITERATION"
|
||||
line = decode_text(p)
|
||||
# Remove trailing period
|
||||
line = line.rstrip(".")
|
||||
# Strip leading/trailing whitespace
|
||||
line = line.strip()
|
||||
if line:
|
||||
current_verse["_padas"].append(line)
|
||||
continue
|
||||
|
||||
# --- Synonyms (p54) ---
|
||||
if cls == SYNONYM_CLASS and current_verse:
|
||||
state = "SYNONYMS"
|
||||
syn_text = decode_text(p)
|
||||
syn_text = syn_text.strip()
|
||||
parsed = parse_synonyms(syn_text)
|
||||
current_verse["synonyms"].extend(parsed)
|
||||
continue
|
||||
|
||||
# --- TRANSLATION / PURPORT label (p55) ---
|
||||
if cls == TRANSLATION_LABEL_CLASS and current_verse:
|
||||
label_text = decode_text(p).strip().upper()
|
||||
if "TRANSLATION" in label_text:
|
||||
state = "TRANSLATION"
|
||||
elif "PURPORT" in label_text:
|
||||
state = "PURPORT"
|
||||
else:
|
||||
# Other labels within purport (e.g., "The Author", "Glossary")
|
||||
if state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Translation text (p56) ---
|
||||
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
|
||||
html = decode_html_content(p)
|
||||
# Remove bold wrapper if present (the whole translation is bold)
|
||||
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
|
||||
html = html.strip()
|
||||
current_verse["translation"] = html
|
||||
state = "POST_TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- PURPORT label (p57) ---
|
||||
if cls == PURPORT_LABEL_CLASS and current_verse:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
|
||||
# --- Purport body paragraphs (p48) ---
|
||||
if state == "PURPORT" and current_verse and cls == PURPORT_BODY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if not is_dashed_line(plain):
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Quoted Sanskrit/Bengali verses within purport (p33, p53, p66) ---
|
||||
if state == "PURPORT" and current_verse and cls in QUOTED_VERSE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Strip inner <i>/<\/i> tags to avoid double wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
current_verse["purport"].append(
|
||||
f'<div style="text-align:center"><i>{stripped}</i></div>'
|
||||
)
|
||||
continue
|
||||
|
||||
# --- Body text / verse translations within purport (p44) ---
|
||||
if state == "PURPORT" and current_verse and cls == BODY_TEXT_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if not is_dashed_line(plain):
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Miscellaneous text (p67) — treat as body text within purport ---
|
||||
if state == "PURPORT" and current_verse and cls == MISC_TEXT_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Section heading within purport (p31) ---
|
||||
if state == "PURPORT" and current_verse and cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Body text classes that might appear in purport (p6, p45) ---
|
||||
if state == "PURPORT" and current_verse and cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
plain = re.sub(r"<[^>]+>", "", html).strip()
|
||||
if not is_dashed_line(plain):
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# Finalize last verse
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
return verses
|
||||
|
||||
|
||||
def _finalize_verse(verse: dict):
|
||||
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
|
||||
padas = verse.get("_padas", [])
|
||||
|
||||
# Transliteration: one pāda per line
|
||||
verse["transliteration"] = "\n".join(padas)
|
||||
|
||||
# Devanagari: pair pādas into 2-line verse structure, then convert
|
||||
if padas:
|
||||
paired = pair_padas(padas)
|
||||
verse["devanagari"] = iast_to_devanagari(paired)
|
||||
|
||||
# Clean up internal field
|
||||
if "_padas" in verse:
|
||||
del verse["_padas"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON writing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write JSON with proper Unicode and formatting."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f" Written: {filepath}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "noi")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
all_paragraphs = soup.find_all("p")
|
||||
print(f"Total paragraphs in file: {len(all_paragraphs)}")
|
||||
|
||||
# --- Find NoI boundaries ---
|
||||
noi_start, noi_end = find_noi_boundaries(all_paragraphs)
|
||||
print(f"NoI content: paragraphs {noi_start} to {noi_end}")
|
||||
|
||||
# --- Find section bookmarks (p64) ---
|
||||
section_bookmarks = find_section_bookmarks(all_paragraphs, noi_start, noi_end)
|
||||
print(f"\nSection bookmarks (p64) found:")
|
||||
for bm_text, bm_idx in sorted(section_bookmarks.items(), key=lambda x: x[1]):
|
||||
print(f" [{bm_idx}] {bm_text}")
|
||||
|
||||
# --- Find verse bookmarks (p49) ---
|
||||
verse_bookmarks, appendixes_idx = find_verse_bookmarks(
|
||||
all_paragraphs, noi_start, noi_end
|
||||
)
|
||||
print(f"\nVerse bookmarks (p49) found: {len(verse_bookmarks)}")
|
||||
for bm_idx, text_num in verse_bookmarks:
|
||||
print(f" [{bm_idx}] NoI: verse {text_num}")
|
||||
if appendixes_idx is not None:
|
||||
print(f" [{appendixes_idx}] NoI Appendixes")
|
||||
|
||||
# Key bookmark indices
|
||||
front_pages_idx = section_bookmarks.get("NoI Front pages")
|
||||
preface_idx = section_bookmarks.get("NoI Preface")
|
||||
|
||||
# First verse bookmark
|
||||
first_verse_idx = verse_bookmarks[0][0] if verse_bookmarks else None
|
||||
|
||||
if first_verse_idx is None:
|
||||
print("ERROR: Could not find any verse bookmarks!")
|
||||
sys.exit(1)
|
||||
|
||||
# End boundary: the Glossary <p class="p55"><b>Glossary</b></p> or noi_end
|
||||
end_boundary = noi_end
|
||||
# Look for the Glossary marker just before the next book
|
||||
for i in range(noi_start, noi_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == TRANSLATION_LABEL_CLASS:
|
||||
text = decode_text(p).strip()
|
||||
if text.upper() == "GLOSSARY":
|
||||
end_boundary = i
|
||||
break
|
||||
|
||||
# --- Extract intro segments ---
|
||||
print("\n--- Extracting Intro Segments ---")
|
||||
|
||||
intro_segments = []
|
||||
|
||||
# 1. Front pages segment — from NoI Front pages (p64) to NoI Preface (p64)
|
||||
front_end = preface_idx if preface_idx is not None else first_verse_idx
|
||||
if front_pages_idx is not None and front_end is not None:
|
||||
front_paras = extract_intro_segment(all_paragraphs, front_pages_idx, front_end)
|
||||
front_segment = {
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "noi",
|
||||
"order": 1,
|
||||
"paragraphs": front_paras,
|
||||
}
|
||||
intro_segments.append(front_segment)
|
||||
print(f" Front Pages: {len(front_paras)} paragraphs")
|
||||
|
||||
# 2. Preface — from NoI Preface (p64) to first verse bookmark
|
||||
if preface_idx is not None and first_verse_idx is not None:
|
||||
preface_paras = extract_intro_segment(
|
||||
all_paragraphs, preface_idx, first_verse_idx
|
||||
)
|
||||
preface_segment = {
|
||||
"slug": "preface",
|
||||
"title": "Preface",
|
||||
"type": "intro-segment",
|
||||
"book": "noi",
|
||||
"order": 2,
|
||||
"paragraphs": preface_paras,
|
||||
}
|
||||
intro_segments.append(preface_segment)
|
||||
print(f" Preface: {len(preface_paras)} paragraphs")
|
||||
|
||||
# 3. Appendixes — from NoI Appendixes (p49) to end boundary
|
||||
if appendixes_idx is not None:
|
||||
appendixes_paras = extract_intro_segment(
|
||||
all_paragraphs, appendixes_idx, end_boundary
|
||||
)
|
||||
appendixes_segment = {
|
||||
"slug": "appendixes",
|
||||
"title": "Appendixes",
|
||||
"type": "intro-segment",
|
||||
"book": "noi",
|
||||
"order": 3,
|
||||
"paragraphs": appendixes_paras,
|
||||
}
|
||||
intro_segments.append(appendixes_segment)
|
||||
print(f" Appendixes: {len(appendixes_paras)} paragraphs")
|
||||
|
||||
# --- Extract verses ---
|
||||
print("\n--- Extracting Verses ---")
|
||||
|
||||
# Verse section runs from first verse bookmark to appendixes (or end boundary)
|
||||
verse_end = appendixes_idx if appendixes_idx is not None else end_boundary
|
||||
verses = extract_verses(all_paragraphs, first_verse_idx, verse_end)
|
||||
print(f"Extracted {len(verses)} texts (expected 11: Texts 1-11)")
|
||||
|
||||
# --- Write intro.json ---
|
||||
texts_listing = []
|
||||
for num in range(1, 12):
|
||||
title = TEXT_TITLES[num]
|
||||
slug = str(num).zfill(2)
|
||||
texts_listing.append({"number": num, "title": title, "slug": slug})
|
||||
|
||||
intro_meta = {
|
||||
"book": "noi",
|
||||
"title": "The Nectar of Instruction",
|
||||
"subtitle": "Śrī Upadeśāmṛta",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1976,
|
||||
"description": "Eleven essential instructions on the science of bhakti-yoga by Śrīla Rūpa Gosvāmī.",
|
||||
"totalTexts": 11,
|
||||
"texts": texts_listing,
|
||||
}
|
||||
write_json(intro_meta, os.path.join(output_base, "intro.json"))
|
||||
|
||||
# --- Write intro segments ---
|
||||
print("\n--- Writing Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
|
||||
# --- Write verse JSON files ---
|
||||
print("\n--- Writing Verse Files ---")
|
||||
for verse in verses:
|
||||
text_num = verse["text"]
|
||||
filename = f"{str(text_num).zfill(2)}.json"
|
||||
filepath = os.path.join(output_base, "verses", filename)
|
||||
write_json(verse, filepath)
|
||||
|
||||
# --- Summary ---
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Book: The Nectar of Instruction (1976 Edition)")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Texts extracted: {len(verses)}")
|
||||
texts_found = sorted([v["text"] for v in verses])
|
||||
if texts_found:
|
||||
print(
|
||||
f" Texts: {texts_found[0]}-{texts_found[-1]} ({len(texts_found)} total)"
|
||||
)
|
||||
else:
|
||||
print(" Texts: None")
|
||||
|
||||
total_purport_paras = sum(len(v["purport"]) for v in verses)
|
||||
total_synonyms = sum(len(v["synonyms"]) for v in verses)
|
||||
print(f" Total purport ¶s: {total_purport_paras}")
|
||||
print(f" Total synonyms: {total_synonyms}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Quality checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all verses have expected fields
|
||||
issues = []
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
if not verse["transliteration"]:
|
||||
issues.append(f" {ref}: missing transliteration")
|
||||
if not verse["devanagari"]:
|
||||
issues.append(f" {ref}: missing devanagari")
|
||||
if not verse["synonyms"]:
|
||||
issues.append(f" {ref}: missing synonyms")
|
||||
if not verse["translation"]:
|
||||
issues.append(f" {ref}: missing translation")
|
||||
if not verse["purport"]:
|
||||
issues.append(f" {ref}: missing purport")
|
||||
|
||||
if issues:
|
||||
print(" ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(issue)
|
||||
else:
|
||||
print(
|
||||
" All texts have transliteration, devanagari, synonyms, translation, and purport ✓"
|
||||
)
|
||||
|
||||
# Check expected text numbers are present
|
||||
expected = set(range(1, 12))
|
||||
found = set(texts_found)
|
||||
missing = expected - found
|
||||
extra = found - expected
|
||||
if missing:
|
||||
print(f" MISSING texts: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" UNEXPECTED texts: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" All 11 texts present ✓")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Text One ---")
|
||||
t1 = [v for v in verses if v["text"] == 1]
|
||||
if t1:
|
||||
v = t1[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Title: {v['title']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Text Six (middle) ---")
|
||||
t6 = [v for v in verses if v["text"] == 6]
|
||||
if t6:
|
||||
v = t6[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Title: {v['title']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Text Eleven (last) ---")
|
||||
t11 = [v for v in verses if v["text"] == 11]
|
||||
if t11:
|
||||
v = t11[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Title: {v['title']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract On Chanting the Hare Kṛṣṇa Mantra (OCHKM) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 4 chapters/sections.
|
||||
There are NO p13 bookmarks — chapters are detected via p27 headings.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/ochkm/intro.json — Book metadata
|
||||
lib/jsons/books/ochkm/intro/*.json — Intro segments
|
||||
lib/jsons/books/ochkm/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/ochkm_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for OCHKM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Chapter heading class (used as chapter boundary since no p13 bookmarks)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Body paragraph classes (all treated as paragraphs)
|
||||
BODY_CLASSES = frozenset(["p6", "p19", "p76", "p25", "p21", "p100"])
|
||||
|
||||
# Classes to skip entirely
|
||||
SKIP_CLASSES = frozenset(["p9", "p15", "p70"])
|
||||
|
||||
# Chapter titles mapping (proper IAST decoded)
|
||||
CHAPTER_TITLES = {
|
||||
1: "Brief Biography of the Spiritual Master",
|
||||
2: "On Chanting the Hare Kṛṣṇa Mantra",
|
||||
3: "What is ISKCON?",
|
||||
4: "Śrī Śrī Śikṣāṣṭakam",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_ochkm_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the OCHKM content.
|
||||
|
||||
Start: <p class="p12"> containing "ON CHANTING THE HARE"
|
||||
End: next <p class="p12"> after start
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = p.get_text().strip().upper()
|
||||
if "ON CHANTING THE HARE" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after OCHKM start = end of OCHKM
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'ON CHANTING THE HARE KṚṢṆA MANTRA' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_headings(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all chapter headings (p27) in the OCHKM range.
|
||||
|
||||
Returns list of (paragraph_index, sequential_chapter_number, chapter_title).
|
||||
"""
|
||||
headings = []
|
||||
seq = 0
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != CHAPTER_HEADING_CLASS:
|
||||
continue
|
||||
|
||||
text = decode_text(p)
|
||||
# Remove bold/italic tags from text content
|
||||
text = re.sub(r"</?[bi]>", "", text).strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
seq += 1
|
||||
# Use pre-defined proper IAST titles
|
||||
ch_title = CHAPTER_TITLES.get(seq, text)
|
||||
headings.append((i, seq, ch_title))
|
||||
|
||||
return headings
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the OCHKM content.
|
||||
Detects italic+br verses in any body class → centered divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip chapter headings (p27) — they are boundaries, not content
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Body paragraphs (p6, p19, p76, p25, p21, p100)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_ochkm(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire OCHKM book from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find OCHKM boundaries
|
||||
ochkm_start, ochkm_end = find_ochkm_boundaries(all_paragraphs)
|
||||
print(f" OCHKM content: paragraphs {ochkm_start} to {ochkm_end}")
|
||||
print(f" Total paragraphs in range: {ochkm_end - ochkm_start}")
|
||||
|
||||
# Step 2: Find all chapter headings (p27)
|
||||
chapter_headings = find_chapter_headings(all_paragraphs, ochkm_start, ochkm_end)
|
||||
print(f" Found {len(chapter_headings)} chapter headings")
|
||||
for pos, ch_num, ch_title in chapter_headings:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (ochkm_start) to first chapter heading
|
||||
intro_segments = []
|
||||
|
||||
if chapter_headings:
|
||||
front_pages_end = chapter_headings[0][0]
|
||||
else:
|
||||
front_pages_end = ochkm_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, ochkm_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "ochkm",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_headings):
|
||||
# Content starts after the heading
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter heading or ochkm_end
|
||||
if idx + 1 < len(chapter_headings):
|
||||
content_end = chapter_headings[idx + 1][0]
|
||||
else:
|
||||
content_end = ochkm_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "ochkm",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ochkm")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting On Chanting the Hare Kṛṣṇa Mantra...\n")
|
||||
result = extract_ochkm(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "ochkm",
|
||||
"title": "On Chanting the Hare Kṛṣṇa Mantra",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1967,
|
||||
"description": "The sublime method for reviving our transcendental consciousness through the chanting of the holy names.",
|
||||
"totalChapters": len(chapters),
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 4 chapters present
|
||||
expected_chapters = set(range(1, 5))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 4 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
if "saṅkīrtana" in all_text or "saṅkértana" in all_text or "saṁkīrtana" in all_text:
|
||||
print(f" ✅ 'saṅkīrtana' variant found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ℹ No centered verse blocks found (may be expected for this book)")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,668 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract On the Way to Kṛṣṇa (1973) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 5 chapters.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/owk/intro.json — Book metadata
|
||||
lib/jsons/books/owk/intro/*.json — Intro segments
|
||||
lib/jsons/books/owk/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/owk_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for OWK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class (OWK uses p85, NOT p12)
|
||||
BOOK_HEADER_CLASS = "p85"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53"])
|
||||
|
||||
# Bold quote paragraphs — include as body
|
||||
BOLD_QUOTE_CLASSES = frozenset(["p56", "p30"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Other books listing text
|
||||
OTHER_BOOKS_CLASS = "p18"
|
||||
|
||||
# Contents header
|
||||
CONTENTS_CLASS = "p69"
|
||||
|
||||
# Misc text (p86 — bold quote paragraph)
|
||||
MISC_CLASS = "p86"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p70"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_owk_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the OWK content.
|
||||
|
||||
Start: <p class="p85"> containing "On the Way" and "1973"
|
||||
End: next <p class="p85"> after start (RĀJA-VIDYĀ)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "On the Way" in text and "1973" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p85 after OWK start = end of OWK
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'On the Way to Kṛṣṇa (1973)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all OWK chapter bookmark tags (p13) in the OWK range.
|
||||
|
||||
Returns list of (paragraph_index, chapter_number, chapter_title).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
m = re.match(r"OWK\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
ch_title = m.group(2).strip()
|
||||
bookmarks.append((i, ch_num, ch_title))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the OWK content.
|
||||
Groups consecutive p53 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p85)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Contents header (p69) — include in front-pages
|
||||
if cls == CONTENTS_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Other books listing (p18) — include in front-pages
|
||||
if cls == OTHER_BOOKS_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Bold quote paragraphs (p56, p30) — include as body
|
||||
if cls in BOLD_QUOTE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Misc text (p86) — include as body
|
||||
if cls == MISC_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_owk(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire On the Way to Kṛṣṇa (1973) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find OWK boundaries
|
||||
owk_start, owk_end = find_owk_boundaries(all_paragraphs)
|
||||
print(f" OWK content: paragraphs {owk_start} to {owk_end}")
|
||||
print(f" Total paragraphs in range: {owk_end - owk_start}")
|
||||
|
||||
# Step 2: Find all chapter bookmarks
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, owk_start, owk_end)
|
||||
print(f" Found {len(chapter_bookmarks)} OWK chapter bookmarks")
|
||||
for pos, ch_num, ch_title in chapter_bookmarks:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (owk_start) to first chapter bookmark (OWK 1)
|
||||
intro_segments = []
|
||||
|
||||
if chapter_bookmarks:
|
||||
front_pages_end = chapter_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = owk_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, owk_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "owk",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or owk_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = owk_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "owk",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "owk")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting On the Way to Kṛṣṇa (1973)...\n")
|
||||
result = extract_owk(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "owk",
|
||||
"title": "On the Way to Kṛṣṇa",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1973,
|
||||
"description": "A guide to achieving happiness and perfection through Kṛṣṇa consciousness.",
|
||||
"totalChapters": 5,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 5 chapters present
|
||||
expected_chapters = set(range(1, 6))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 5 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,714 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract The Perfection of Yoga (1972) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 11 chapters.
|
||||
Chapters come from TWO bookmark prefixes:
|
||||
- PoY 1–8: "The Perfection of Yoga" chapters
|
||||
- TYS 4–6: "The Topmost Yoga System" bonus chapters (renumbered 9–11)
|
||||
|
||||
Output:
|
||||
lib/jsons/books/poy/intro.json — Book metadata
|
||||
lib/jsons/books/poy/intro/*.json — Intro segments
|
||||
lib/jsons/books/poy/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/poy_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for POY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class (p85 for POY)
|
||||
BOOK_HEADER_CLASS = "p85"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53"])
|
||||
|
||||
# Bold quote paragraphs — include as body
|
||||
BOLD_QUOTE_CLASSES = frozenset(["p56"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Contents heading class
|
||||
CONTENTS_CLASS = "p69"
|
||||
|
||||
# Misc classes to include as body
|
||||
MISC_BODY_CLASSES = frozenset(["p90", "p91"])
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p15", "p25", "p70"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter title mapping (ScaGoudy → proper IAST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHAPTER_TITLES = {
|
||||
1: "Yoga as Reestablishing Relations with Kṛṣṇa",
|
||||
2: "Yoga as Work in Devotion",
|
||||
3: "Yoga as Meditation on Kṛṣṇa",
|
||||
4: "Yoga as Rejected by Arjuna",
|
||||
5: "Yoga as Body and Mind Control",
|
||||
6: "Yoga as Freedom from Duality and Designation",
|
||||
7: "The Fate of the Unsuccessful Yogī",
|
||||
8: "The Perfection of Yoga",
|
||||
9: "The Hare Kṛṣṇa Mantra",
|
||||
10: "How Bhakti-yoga Works",
|
||||
11: "Sources of Absolute Knowledge",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_poy_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the POY content.
|
||||
|
||||
Start: <p class="p85"> containing "Perfection of Yoga" and "1972"
|
||||
End: next <p class="p85"> after start, or end of document
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Perfection of Yoga" in text and "1972" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p85 after POY start = end of POY
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'The Perfection of Yoga (1972)' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_glossary_boundary(all_paragraphs: list, start_idx: int, end_idx: int) -> int:
|
||||
"""Find the Glossary heading within POY range.
|
||||
|
||||
Returns the paragraph index of the Glossary heading, or end_idx if not found.
|
||||
"""
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if "Glossary" in text:
|
||||
return i
|
||||
return end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all POY + TYS chapter bookmark tags (p13) in the POY range.
|
||||
|
||||
Parses both "PoY N:" and "TYS N:" patterns.
|
||||
TYS 4→ch9, TYS 5→ch10, TYS 6→ch11.
|
||||
|
||||
Returns list of (paragraph_index, sequential_chapter_number, chapter_title).
|
||||
"""
|
||||
bookmarks = []
|
||||
seq_counter = 0
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
m = re.match(r"(?:PoY|TYS)\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
seq_counter += 1
|
||||
ch_title_raw = m.group(2).strip()
|
||||
# Use our pre-defined proper IAST titles
|
||||
ch_title = CHAPTER_TITLES.get(seq_counter, ch_title_raw)
|
||||
bookmarks.append((i, seq_counter, ch_title))
|
||||
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the POY content.
|
||||
Groups consecutive p53 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p85)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Contents heading (p69) — include in front-pages
|
||||
if cls == CONTENTS_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Bold quote paragraphs (p56) — include as body
|
||||
if cls in BOLD_QUOTE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Misc body classes (p90, p91)
|
||||
if cls in MISC_BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_poy(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Perfection of Yoga (1972) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find POY boundaries
|
||||
poy_start, poy_end = find_poy_boundaries(all_paragraphs)
|
||||
print(f" POY content: paragraphs {poy_start} to {poy_end}")
|
||||
print(f" Total paragraphs in range: {poy_end - poy_start}")
|
||||
|
||||
# Step 2: Find Glossary boundary — everything from Glossary onward is excluded
|
||||
glossary_idx = find_glossary_boundary(all_paragraphs, poy_start, poy_end)
|
||||
if glossary_idx < poy_end:
|
||||
print(f" Glossary found at paragraph {glossary_idx} — trimming end boundary")
|
||||
poy_end = glossary_idx
|
||||
else:
|
||||
print(f" No Glossary heading found — using full range")
|
||||
|
||||
print(f" Effective content range: paragraphs {poy_start} to {poy_end}")
|
||||
|
||||
# Step 3: Find all chapter bookmarks (PoY 1-8, TYS 4-6)
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, poy_start, poy_end)
|
||||
print(f" Found {len(chapter_bookmarks)} chapter bookmarks")
|
||||
for pos, ch_num, ch_title in chapter_bookmarks:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 4: Extract front pages
|
||||
# From book start (poy_start) to first chapter bookmark
|
||||
intro_segments = []
|
||||
|
||||
if chapter_bookmarks:
|
||||
front_pages_end = chapter_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = poy_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, poy_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "poy",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 5: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or poy_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = poy_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "poy",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 6: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "poy")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting The Perfection of Yoga (1972)...\n")
|
||||
result = extract_poy(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "poy",
|
||||
"title": "The Perfection of Yoga",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1972,
|
||||
"description": "The complete science of yoga as taught by Lord Kṛṣṇa in the Bhagavad-gītā.",
|
||||
"totalChapters": 11,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 11 chapters present
|
||||
expected_chapters = set(range(1, 12))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 11 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,848 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Perfect Questions Perfect Answers (1977) from other.html.
|
||||
|
||||
The book has 3 intro segments (front-pages, introduction, about-the-author),
|
||||
9 chapters, and a Concluding Words section.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/pqpa/intro.json — Book metadata
|
||||
lib/jsons/books/pqpa/intro/*.json — Intro segments
|
||||
lib/jsons/books/pqpa/chapters/*.json — Chapters + concluding words
|
||||
|
||||
Usage:
|
||||
python3 scripts/pqpa_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for PQPA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Bookmark class — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6", "p48"])
|
||||
|
||||
# Dialogue line class — the main content class (714 occurrences)
|
||||
DIALOGUE_CLASS = "p92"
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53", "p66"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Section heading (bold)
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
|
||||
# Front pages text
|
||||
FRONT_PAGES_CLASS = "p18"
|
||||
|
||||
# Attribution text
|
||||
ATTRIBUTION_CLASS = "p42"
|
||||
|
||||
# Numbered items / verse quotes
|
||||
NUMBERED_CLASS = "p24"
|
||||
|
||||
# Mantra text
|
||||
MANTRA_CLASS = "p19"
|
||||
|
||||
# Contents heading
|
||||
CONTENTS_CLASS = "p69"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Misc classes to include
|
||||
MISC_CLASSES = frozenset(["p28", "p76", "p93", "p94", "p95", "p96"])
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p15", "p70"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_pqpa_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the PQPA content.
|
||||
|
||||
Start: <p class="p12"> containing "Perfect Questions Perfect Answers"
|
||||
End: next <p class="p12"> after start, or end of file
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Perfect Questions Perfect Answers" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after PQPA start = end of PQPA
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Perfect Questions Perfect Answers' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for Glossary marker as end boundary
|
||||
for i in range(start_idx + 1, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if text == "Glossary":
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all p13 bookmark tags in the PQPA range.
|
||||
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
The bookmark text is stripped of any span noise like <span class="s27"> </span>.
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
# Get text, stripping span noise
|
||||
text = decode_text(p).strip()
|
||||
if text.startswith("PQPA"):
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str):
|
||||
"""Parse a chapter bookmark like 'PQPA 1: Kṛṣṇa, the All-Attractive'.
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"PQPA\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
return int(m.group(1)), m.group(2).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str) -> bool:
|
||||
"""Check if this is the Introduction bookmark."""
|
||||
return text.strip() == "PQPA Introduction"
|
||||
|
||||
|
||||
def parse_concluding_bookmark(text: str) -> bool:
|
||||
"""Check if this is the Concluding Words bookmark."""
|
||||
return text.strip() == "PQPA Concluding Words"
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter, intro segment, or conclusion).
|
||||
|
||||
Handles all CSS classes within the PQPA content.
|
||||
Groups consecutive p53/p66 verse pādas into centered italic divs.
|
||||
Detects verse patterns in p6/p92 (starts with <i>, has <br>).
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53, p66) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Dialogue lines (p92) — the main content class
|
||||
if cls == DIALOGUE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect verses disguised as dialogue: starts with <i> and has <br>
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section heading (p31) — include as paragraph
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Front pages text (p18) — include
|
||||
if cls == FRONT_PAGES_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Contents heading (p69) — include
|
||||
if cls == CONTENTS_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Attribution text (p42)
|
||||
if cls == ATTRIBUTION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Numbered items / verse quotes (p24)
|
||||
if cls == NUMBERED_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect verses: starts with <i> and has <br>
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Mantra text (p19) — wrap in centered div
|
||||
if cls == MANTRA_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Check if it's a multi-line verse
|
||||
if "<br" in html:
|
||||
inner = re.sub(r"</?i>", "", html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6, p48)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Misc classes — include as-is
|
||||
if cls in MISC_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_pqpa(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Perfect Questions Perfect Answers from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"concluding_words": concluding words dict
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find PQPA boundaries
|
||||
pqpa_start, pqpa_end = find_pqpa_boundaries(all_paragraphs)
|
||||
print(f" PQPA content: paragraphs {pqpa_start} to {pqpa_end}")
|
||||
print(f" Total paragraphs in range: {pqpa_end - pqpa_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, pqpa_start, pqpa_end)
|
||||
print(f" Found {len(bookmarks)} PQPA bookmarks")
|
||||
for pos, text in bookmarks:
|
||||
print(f" Line ~{pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks
|
||||
intro_bookmark = None # (para_idx,)
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
concluding_bookmark = None # (para_idx,)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
elif parse_intro_bookmark(text):
|
||||
intro_bookmark = pos
|
||||
elif parse_concluding_bookmark(text):
|
||||
concluding_bookmark = pos
|
||||
|
||||
print(
|
||||
f" Introduction bookmark: {'found' if intro_bookmark is not None else 'NOT FOUND'}"
|
||||
)
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
print(
|
||||
f" Concluding Words bookmark: {'found' if concluding_bookmark is not None else 'NOT FOUND'}"
|
||||
)
|
||||
|
||||
# Collect all section starts for boundary computation
|
||||
all_section_starts = sorted(
|
||||
([intro_bookmark] if intro_bookmark is not None else [])
|
||||
+ [pos for pos, _, _ in chapter_bookmarks]
|
||||
+ ([concluding_bookmark] if concluding_bookmark is not None else [])
|
||||
)
|
||||
|
||||
# Step 4: Extract front-pages (from p12 header to Introduction bookmark)
|
||||
front_pages_start = pqpa_start + 1 # after p12 header
|
||||
front_pages_end = (
|
||||
intro_bookmark
|
||||
if intro_bookmark is not None
|
||||
else (chapter_bookmarks[0][0] if chapter_bookmarks else pqpa_end)
|
||||
)
|
||||
|
||||
front_pages_paras = extract_paragraphs(
|
||||
all_paragraphs, front_pages_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
|
||||
front_pages = {
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "pqpa",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paras,
|
||||
}
|
||||
|
||||
# Step 5: Extract Introduction (from PQPA Introduction to PQPA 1)
|
||||
introduction = None
|
||||
if intro_bookmark is not None:
|
||||
intro_content_start = intro_bookmark + 1
|
||||
# End at the first chapter bookmark
|
||||
intro_content_end = chapter_bookmarks[0][0] if chapter_bookmarks else pqpa_end
|
||||
intro_paras = extract_paragraphs(
|
||||
all_paragraphs, intro_content_start, intro_content_end
|
||||
)
|
||||
introduction = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "pqpa",
|
||||
"order": 2,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
|
||||
# Step 6: Extract chapters 1-9
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark, or concluding words, or pqpa_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
elif concluding_bookmark is not None:
|
||||
content_end = concluding_bookmark
|
||||
else:
|
||||
content_end = pqpa_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "pqpa",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 7: Extract Concluding Words + About the Author
|
||||
concluding_words = None
|
||||
about_the_author = None
|
||||
|
||||
if concluding_bookmark is not None:
|
||||
# Find the "About the Author" boundary:
|
||||
# <p class="p27"><b>His Divine Grace A. C. Bhaktivedanta Swami Prabhupāda</b></p>
|
||||
about_author_pos = None
|
||||
for i in range(concluding_bookmark + 1, pqpa_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
text = decode_text(p)
|
||||
if "His Divine Grace" in text:
|
||||
about_author_pos = i
|
||||
break
|
||||
|
||||
# Concluding Words: from bookmark to About the Author (or pqpa_end)
|
||||
cw_content_start = concluding_bookmark + 1
|
||||
cw_content_end = about_author_pos if about_author_pos is not None else pqpa_end
|
||||
|
||||
cw_paras = extract_paragraphs(all_paragraphs, cw_content_start, cw_content_end)
|
||||
|
||||
concluding_words = {
|
||||
"book": "pqpa",
|
||||
"chapter": None,
|
||||
"title": "Concluding Words",
|
||||
"type": "conclusion",
|
||||
"slug": "concluding-words",
|
||||
"paragraphs": cw_paras,
|
||||
}
|
||||
|
||||
# About the Author: from p27 heading to pqpa_end
|
||||
if about_author_pos is not None:
|
||||
ata_paras = extract_paragraphs(all_paragraphs, about_author_pos, pqpa_end)
|
||||
about_the_author = {
|
||||
"slug": "about-the-author",
|
||||
"title": "About the Author",
|
||||
"type": "intro-segment",
|
||||
"book": "pqpa",
|
||||
"order": 99,
|
||||
"paragraphs": ata_paras,
|
||||
}
|
||||
|
||||
# Step 8: Build intro segments list
|
||||
intro_segments = [front_pages]
|
||||
if introduction:
|
||||
intro_segments.append(introduction)
|
||||
if about_the_author:
|
||||
intro_segments.append(about_the_author)
|
||||
|
||||
# Step 9: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"concluding_words": concluding_words,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "pqpa")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Perfect Questions Perfect Answers (1977)...\n")
|
||||
result = extract_pqpa(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
concluding_words = result["concluding_words"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "pqpa",
|
||||
"title": "Perfect Questions Perfect Answers",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1977,
|
||||
"description": "Conversations between Śrīla Prabhupāda and Bob Cohen, a Peace Corps worker in India.",
|
||||
"totalChapters": 9,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Write concluding words ---
|
||||
if concluding_words:
|
||||
filepath = os.path.join(output_base, "chapters", "concluding-words.json")
|
||||
write_json(concluding_words, filepath)
|
||||
print(
|
||||
f"\n Concluding Words: {len(concluding_words['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_concluding_paras = (
|
||||
len(concluding_words["paragraphs"]) if concluding_words else 0
|
||||
)
|
||||
total_paras = total_intro_paras + total_chapter_paras + total_concluding_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Concluding Words: {'Yes' if concluding_words else 'No'}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
print(f" Concluding: {total_concluding_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:4]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 9:
|
||||
print(f"\n--- Sample: Chapter 9 (last) ---")
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if concluding_words:
|
||||
print(f"\n--- Sample: Concluding Words ---")
|
||||
for i, p in enumerate(concluding_words["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 9 chapters present
|
||||
expected_chapters = set(range(1, 10))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 9 chapters present")
|
||||
|
||||
# Check intro segments
|
||||
expected_intros = {"front-pages", "introduction", "about-the-author"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
else:
|
||||
print(f" ✅ All 3 intro segments present")
|
||||
|
||||
# Check concluding words
|
||||
if concluding_words:
|
||||
print(f" ✅ Concluding Words present")
|
||||
else:
|
||||
print(f" ⚠ Concluding Words NOT found")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
all_sections = chapters + intro_segments
|
||||
if concluding_words:
|
||||
all_sections = all_sections + [concluding_words]
|
||||
for section in all_sections:
|
||||
for p in section["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
print(f" ℹ {verse_block_count} verse blocks in chapters")
|
||||
|
||||
# ScaGoudy decoding spot check
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:10])
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
|
||||
elif "Kåñëa" in ch1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 10 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
# Check for "Śrīla Prabhupāda"
|
||||
if "Prabhupāda" in ch1_text:
|
||||
print(f" ✅ 'Prabhupāda' correctly decoded")
|
||||
elif "Prabhupäda" in ch1_text:
|
||||
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
|
||||
|
||||
# Dialogue line check — look for BOB: and ŚRĪLA PRABHUPĀDA: speakers
|
||||
print(f"\n--- Dialogue Speaker Check ---")
|
||||
speaker_counts = {}
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
# Look for bold speaker patterns
|
||||
m = re.match(r"<b>([^<]+):</b>", p)
|
||||
if m:
|
||||
speaker = m.group(1).strip()
|
||||
speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1
|
||||
if speaker_counts:
|
||||
print(f" Speakers found:")
|
||||
for speaker, count in sorted(speaker_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {speaker}: {count} lines")
|
||||
else:
|
||||
print(f" ⚠ No dialogue speakers detected")
|
||||
|
||||
# Sample verse blocks
|
||||
print(f"\n--- Sample Verse Blocks ---")
|
||||
shown = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
shown += 1
|
||||
if shown >= 3:
|
||||
break
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,647 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Rāja-vidyā: The King of Knowledge (1973) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 8 chapters.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/rv/intro.json — Book metadata
|
||||
lib/jsons/books/rv/intro/*.json — Intro segments
|
||||
lib/jsons/books/rv/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/rv_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for RV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class (multi-line with <br>)
|
||||
BOOK_HEADER_CLASS = "p85"
|
||||
|
||||
# Chapter bookmark class
|
||||
CHAPTER_BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p53"])
|
||||
|
||||
# Verse translations / body text
|
||||
TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Title page text
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Contents heading classes
|
||||
CONTENTS_CLASSES = frozenset(["p87", "p88"])
|
||||
|
||||
# Back-cover blurb class
|
||||
BACK_COVER_CLASS = "p89"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p70", "p15"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_rv_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the RV content.
|
||||
|
||||
Start: <p class="p85"> containing "King of Knowledge" and "1973"
|
||||
End: next <p class="p85"> after start, or <p class="p89"> back-cover text
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "King of Knowledge" in text and "1973" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p85 after RV start = end of RV
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'King of Knowledge (1973)' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for p89 back-cover text as potential end boundary
|
||||
# The p89 tag appears right before the next p85 and is part of RV
|
||||
# so we keep end_idx at the next p85 (or end of file)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all RV chapter bookmark tags (p13) in the RV range.
|
||||
|
||||
Returns list of (paragraph_index, chapter_number, chapter_title).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
m = re.match(r"RV\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
ch_title = m.group(2).strip()
|
||||
bookmarks.append((i, ch_num, ch_title))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the RV content.
|
||||
Groups consecutive p53 verse pādas into centered italic divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == CHAPTER_BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p85)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip back-cover blurb (p89)
|
||||
if cls == BACK_COVER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p53) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page text (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Contents heading classes (p87, p88) — include in front-pages
|
||||
if cls in CONTENTS_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation / body text (p44)
|
||||
if cls == TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_rv(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Rāja-vidyā: The King of Knowledge (1973) from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find RV boundaries
|
||||
rv_start, rv_end = find_rv_boundaries(all_paragraphs)
|
||||
print(f" RV content: paragraphs {rv_start} to {rv_end}")
|
||||
print(f" Total paragraphs in range: {rv_end - rv_start}")
|
||||
|
||||
# Step 2: Find all chapter bookmarks
|
||||
chapter_bookmarks = find_chapter_bookmarks(all_paragraphs, rv_start, rv_end)
|
||||
print(f" Found {len(chapter_bookmarks)} RV chapter bookmarks")
|
||||
for pos, ch_num, ch_title in chapter_bookmarks:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (rv_start) to first chapter bookmark (RV 1)
|
||||
intro_segments = []
|
||||
|
||||
if chapter_bookmarks:
|
||||
front_pages_end = chapter_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = rv_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, rv_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "rv",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or rv_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = rv_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "rv",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "rv")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Rāja-vidyā: The King of Knowledge (1973)...\n")
|
||||
result = extract_rv(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "rv",
|
||||
"title": "Rāja-vidyā: The King of Knowledge",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1973,
|
||||
"description": "The king of knowledge — the most confidential wisdom of the Bhagavad-gītā.",
|
||||
"totalChapters": 8,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 8 chapters present
|
||||
expected_chapters = set(range(1, 9))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 8 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
# Check for properly decoded words
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Bhagavad-gītā" in all_text:
|
||||
print(f" ✅ 'Bhagavad-gītā' found (ScaGoudy properly decoded)")
|
||||
elif "gītā" in all_text or "Gītā" in all_text:
|
||||
print(f" ✅ 'gītā' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Gītā' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants (common encoded chars)
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-bbd.ts — Push Beyond Birth and Death JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-ek.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* BBD is a prose book (like EK) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "bbd", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 5 chapters = 6 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-bbd.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const BBD_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "bbd");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BbdIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type BbdIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type BbdChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedBbd(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(BBD_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ BBD intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<BbdIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "bbd" });
|
||||
await metaCol.insertOne({ book: "bbd", data: intro });
|
||||
console.log(" 📖 BBD book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "bbd" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(BBD_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<BbdIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "bbd",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 05.json)
|
||||
const chaptersDir = path.join(BBD_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<BbdChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "bbd",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "bbd" });
|
||||
console.log(` ✅ BBD: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Beyond Birth and Death...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedBbd(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* seed-ds.ts — Push Dialectic Spiritualism JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-ssr.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* DS is a large prose/dialogue book with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Sub-chapters are stored with slugs like "1-1", "1-2", "2-1", etc.
|
||||
* Each sub-chapter JSON has parentChapter and subChapter fields used to
|
||||
* compute a stable ordering: parentChapter * 100 + subChapter.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "ds", data: <intro.json> }
|
||||
* - prose_chapters → 3 intro segments + 38 sub-chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-ds.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const DS_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ds");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DsIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: {
|
||||
number: number;
|
||||
title: string;
|
||||
subChapters: { slug: string; title: string }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type DsIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type DsChapterJson = {
|
||||
book: string;
|
||||
chapter: string; // "1-1", "2-3", etc.
|
||||
parentChapter: number;
|
||||
subChapter: number;
|
||||
title: string;
|
||||
parentTitle: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedDs(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(DS_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ DS intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<DsIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "ds" });
|
||||
await metaCol.insertOne({ book: "ds", data: intro });
|
||||
console.log(" 📖 DS book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "ds" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (3 total: front-pages, foreword, preface)
|
||||
const introSegmentFiles = [
|
||||
"front-pages.json",
|
||||
"foreword.json",
|
||||
"preface.json",
|
||||
];
|
||||
|
||||
const introDir = path.join(DS_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<DsIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "ds",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Sub-chapters (1-1.json, 1-2.json, 2-1.json, etc.)
|
||||
const chaptersDir = path.join(DS_DIR, "chapters");
|
||||
let chapterCount = 0;
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort((a, b) => {
|
||||
// Sort by parentChapter then subChapter numerically
|
||||
const parseSlug = (name: string) => {
|
||||
const slug = name.replace(".json", "");
|
||||
const parts = slug.split("-");
|
||||
return {
|
||||
parent: parseInt(parts[0], 10),
|
||||
sub: parseInt(parts[1], 10),
|
||||
};
|
||||
};
|
||||
const aa = parseSlug(a);
|
||||
const bb = parseSlug(b);
|
||||
if (aa.parent !== bb.parent) return aa.parent - bb.parent;
|
||||
return aa.sub - bb.sub;
|
||||
});
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<DsChapterJson>(path.join(chaptersDir, file));
|
||||
const order = ch.parentChapter * 100 + ch.subChapter;
|
||||
|
||||
docs.push({
|
||||
book: "ds",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null, // null since chapter is a string "1-1" not an int
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: ch.chapter, // "1-1", "1-2", "2-1", etc.
|
||||
order,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
chapterCount++;
|
||||
}
|
||||
console.log(` 📖 Sub-chapters: ${chapterCount}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "ds" });
|
||||
console.log(` ✅ DS: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Dialectic Spiritualism...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedDs(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* seed-ej.ts — Push Easy Journey to Other Planets JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* EJ is a prose book (like NoD/TLC) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "ej", data: <intro.json> }
|
||||
* - prose_chapters → 2 intro segments + 2 chapters = 4 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-ej.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const EJ_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ej");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EjIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type EjIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type EjChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedEj(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(EJ_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ EJ intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<EjIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "ej" });
|
||||
await metaCol.insertOne({ book: "ej", data: intro });
|
||||
console.log(" 📖 EJ book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "ej" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = [
|
||||
"front-pages.json",
|
||||
"preface.json",
|
||||
];
|
||||
|
||||
const introDir = path.join(EJ_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<EjIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "ej",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json, 02.json)
|
||||
const chaptersDir = path.join(EJ_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<EjChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "ej",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "ej" });
|
||||
console.log(` ✅ EJ: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Easy Journey to Other Planets...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedEj(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-ek.ts — Push Elevation to Kṛṣṇa Consciousness JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-ej.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* EK is a prose book (like EJ) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "ek", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 6 chapters = 7 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-ek.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const EK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ek");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EkIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type EkIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type EkChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedEk(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(EK_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ EK intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<EkIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "ek" });
|
||||
await metaCol.insertOne({ book: "ek", data: intro });
|
||||
console.log(" 📖 EK book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "ek" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(EK_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<EkIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "ek",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 06.json)
|
||||
const chaptersDir = path.join(EK_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<EkChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "ek",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "ek" });
|
||||
console.log(` ✅ EK: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Elevation to Kṛṣṇa Consciousness...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedEk(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-hkh.ts — Push The Kṛṣṇa Consciousness Handbook JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-owk.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* HKH is a prose book with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "hkh", data: <intro.json> }
|
||||
* - prose_chapters → 2 intro segments + 7 chapters = 9 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-hkh.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const HKH_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "hkh");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type HkhIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type HkhIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type HkhChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedHkh(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(HKH_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ HKH intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<HkhIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "hkh" });
|
||||
await metaCol.insertOne({ book: "hkh", data: intro });
|
||||
console.log(" 📖 HKH book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "hkh" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json", "introduction.json"];
|
||||
|
||||
const introDir = path.join(HKH_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<HkhIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "hkh",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 07.json)
|
||||
const chaptersDir = path.join(HKH_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<HkhChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "hkh",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "hkh" });
|
||||
console.log(` ✅ HKH: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding The Kṛṣṇa Consciousness Handbook...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedHkh(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* seed-iso.ts — Push Śrī Īśopaniṣad JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* ISO is a verse book (like BG) but also has prose intro segments.
|
||||
* We use volume=0 as the sentinel for "no volumes" on the prose side.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "iso", data: <intro.json> }
|
||||
* - verses → 19 verse docs (Invocation + Mantras 1-18)
|
||||
* - prose_chapters → 3 intro segment docs (front-pages, introduction, about-the-author)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-iso.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const ISO_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "iso");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type IsoIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalMantras: number;
|
||||
mantras: { number: number; title: string; slug: string }[];
|
||||
};
|
||||
|
||||
type IsoVerseJson = {
|
||||
book: string;
|
||||
book_title: string;
|
||||
mantra: number;
|
||||
title: string;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
type IsoIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedIso(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(ISO_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ ISO intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<IsoIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "iso" });
|
||||
await metaCol.insertOne({ book: "iso", data: intro });
|
||||
console.log(" 📖 ISO book meta inserted");
|
||||
|
||||
// ---- 2. Verses ----
|
||||
const versesCol = db.collection("verses");
|
||||
await versesCol.deleteMany({ book: "iso" });
|
||||
|
||||
const verseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
// Verse files: invocation.json, 01.json through 18.json
|
||||
const verseFiles = [
|
||||
"invocation.json",
|
||||
...Array.from({ length: 18 }, (_, i) =>
|
||||
String(i + 1).padStart(2, "0") + ".json"
|
||||
),
|
||||
];
|
||||
|
||||
const versesDir = path.join(ISO_DIR, "verses");
|
||||
for (const file of verseFiles) {
|
||||
const filePath = path.join(versesDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = readJson<IsoVerseJson>(filePath);
|
||||
|
||||
verseDocs.push({
|
||||
book: "iso",
|
||||
division: null,
|
||||
chapter: v.mantra,
|
||||
verse: 1,
|
||||
verseEnd: null,
|
||||
reference: v.reference,
|
||||
bookTitle: v.book_title,
|
||||
devanagari: v.devanagari,
|
||||
bengali: null,
|
||||
transliteration: v.transliteration,
|
||||
synonyms: v.synonyms,
|
||||
translation: v.translation,
|
||||
purport: v.purport,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
|
||||
console.log(` Inserting ${verseDocs.length} verse documents...`);
|
||||
await insertBatched(versesCol, verseDocs);
|
||||
|
||||
const totalVerses = await versesCol.countDocuments({ book: "iso" });
|
||||
console.log(` ✅ ISO verses: ${totalVerses} in MongoDB`);
|
||||
|
||||
// ---- 3. Prose intro segments ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "iso" });
|
||||
|
||||
const proseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
const introSegmentFiles = [
|
||||
"front-pages.json",
|
||||
"introduction.json",
|
||||
"about-the-author.json",
|
||||
];
|
||||
|
||||
const introDir = path.join(ISO_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<IsoIntroSegmentJson>(filePath);
|
||||
|
||||
proseDocs.push({
|
||||
book: "iso",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
|
||||
console.log(` Inserting ${proseDocs.length} prose documents...`);
|
||||
await insertBatched(proseCol, proseDocs);
|
||||
|
||||
const totalProse = await proseCol.countDocuments({ book: "iso" });
|
||||
console.log(` ✅ ISO prose: ${totalProse} entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Śrī Īśopaniṣad...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedIso(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-mg.ts — Push Kṛṣṇa Consciousness: The Matchless Gift JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-ek.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* MG is a prose book (like EK) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "mg", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 7 chapters = 8 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-mg.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const MG_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "mg");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MgIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type MgIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type MgChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedMg(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(MG_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ MG intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<MgIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "mg" });
|
||||
await metaCol.insertOne({ book: "mg", data: intro });
|
||||
console.log(" 📖 MG book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "mg" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(MG_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<MgIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "mg",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 07.json)
|
||||
const chaptersDir = path.join(MG_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<MgChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "mg",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "mg" });
|
||||
console.log(` ✅ MG: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Kṛṣṇa Consciousness: The Matchless Gift...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedMg(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* seed-nod.ts — Push The Nectar of Devotion JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-db.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* NoD is a prose book (like KB) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "nod", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 51 chapters + concluding words
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-nod.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const NOD_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "nod");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NodIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
parts: { number: number; title: string; chapters: number[] }[];
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type NodIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type NodChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedNod(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(NOD_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ NoD intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<NodIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "nod" });
|
||||
await metaCol.insertOne({ book: "nod", data: intro });
|
||||
console.log(" 📖 NoD book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "nod" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introDir = path.join(NOD_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<NodIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "nod",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 51.json)
|
||||
const chaptersDir = path.join(NOD_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json") && f !== "concluding-words.json")
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<NodChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "nod",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
|
||||
// Concluding words
|
||||
const concludingPath = path.join(chaptersDir, "concluding-words.json");
|
||||
if (fs.existsSync(concludingPath)) {
|
||||
const cw = readJson<NodChapterJson>(concludingPath);
|
||||
docs.push({
|
||||
book: "nod",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: cw.title,
|
||||
type: cw.type || "conclusion",
|
||||
slug: "concluding-words",
|
||||
order: 9999,
|
||||
paragraphs: cw.paragraphs,
|
||||
});
|
||||
console.log(" 📝 Concluding words: 1");
|
||||
}
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "nod" });
|
||||
console.log(` ✅ NoD: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding The Nectar of Devotion...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedNod(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* seed-noi.ts — Push The Nectar of Instruction JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* NoI is a verse book (like ISO) with prose intro segments.
|
||||
* We use volume=0 as the sentinel for "no volumes" on the prose side.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "noi", data: <intro.json> }
|
||||
* - verses → 11 verse docs (Texts 1-11)
|
||||
* - prose_chapters → 3 intro segment docs (front-pages, preface, appendixes)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-noi.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const NOI_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "noi");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NoiIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalTexts: number;
|
||||
texts: { number: number; title: string; slug: string }[];
|
||||
};
|
||||
|
||||
type NoiVerseJson = {
|
||||
book: string;
|
||||
book_title: string;
|
||||
text: number;
|
||||
title: string;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
type NoiIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedNoi(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(NOI_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ NoI intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<NoiIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "noi" });
|
||||
await metaCol.insertOne({ book: "noi", data: intro });
|
||||
console.log(" 📖 NoI book meta inserted");
|
||||
|
||||
// ---- 2. Verses ----
|
||||
const versesCol = db.collection("verses");
|
||||
await versesCol.deleteMany({ book: "noi" });
|
||||
|
||||
const verseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
// Verse files: 01.json through 11.json
|
||||
const verseFiles = Array.from({ length: 11 }, (_, i) =>
|
||||
String(i + 1).padStart(2, "0") + ".json"
|
||||
);
|
||||
|
||||
const versesDir = path.join(NOI_DIR, "verses");
|
||||
for (const file of verseFiles) {
|
||||
const filePath = path.join(versesDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = readJson<NoiVerseJson>(filePath);
|
||||
|
||||
verseDocs.push({
|
||||
book: "noi",
|
||||
division: null,
|
||||
chapter: v.text,
|
||||
verse: 1,
|
||||
verseEnd: null,
|
||||
reference: v.reference,
|
||||
bookTitle: v.book_title,
|
||||
devanagari: v.devanagari,
|
||||
bengali: null,
|
||||
transliteration: v.transliteration,
|
||||
synonyms: v.synonyms,
|
||||
translation: v.translation,
|
||||
purport: v.purport,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
|
||||
console.log(` Inserting ${verseDocs.length} verse documents...`);
|
||||
await insertBatched(versesCol, verseDocs);
|
||||
|
||||
const totalVerses = await versesCol.countDocuments({ book: "noi" });
|
||||
console.log(` ✅ NoI verses: ${totalVerses} in MongoDB`);
|
||||
|
||||
// ---- 3. Prose intro segments ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "noi" });
|
||||
|
||||
const proseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
const introSegmentFiles = [
|
||||
"front-pages.json",
|
||||
"preface.json",
|
||||
"appendixes.json",
|
||||
];
|
||||
|
||||
const introDir = path.join(NOI_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<NoiIntroSegmentJson>(filePath);
|
||||
|
||||
proseDocs.push({
|
||||
book: "noi",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
|
||||
console.log(` Inserting ${proseDocs.length} prose documents...`);
|
||||
await insertBatched(proseCol, proseDocs);
|
||||
|
||||
const totalProse = await proseCol.countDocuments({ book: "noi" });
|
||||
console.log(` ✅ NoI prose: ${totalProse} entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding The Nectar of Instruction...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedNoi(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-ochkm.ts — Push On Chanting the Hare Kṛṣṇa Mantra JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-poy.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* OCHKM is a prose book (like POY) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "ochkm", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 4 chapters = 5 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-ochkm.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const OCHKM_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ochkm");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OchkmIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type OchkmIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type OchkmChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedOchkm(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(OCHKM_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ OCHKM intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<OchkmIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "ochkm" });
|
||||
await metaCol.insertOne({ book: "ochkm", data: intro });
|
||||
console.log(" 📖 OCHKM book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "ochkm" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(OCHKM_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<OchkmIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "ochkm",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 04.json)
|
||||
const chaptersDir = path.join(OCHKM_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<OchkmChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "ochkm",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "ochkm" });
|
||||
console.log(` ✅ OCHKM: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding On Chanting the Hare Kṛṣṇa Mantra...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedOchkm(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-owk.ts — Push On the Way to Kṛṣṇa JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-bbd.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* OWK is a prose book (like BBD) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "owk", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 5 chapters = 6 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-owk.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const OWK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "owk");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OwkIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type OwkIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type OwkChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedOwk(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(OWK_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ OWK intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<OwkIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "owk" });
|
||||
await metaCol.insertOne({ book: "owk", data: intro });
|
||||
console.log(" 📖 OWK book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "owk" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(OWK_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<OwkIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "owk",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 05.json)
|
||||
const chaptersDir = path.join(OWK_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<OwkChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "owk",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "owk" });
|
||||
console.log(` ✅ OWK: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding On the Way to Kṛṣṇa...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedOwk(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-poy.ts — Push The Perfection of Yoga JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-bbd.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* POY is a prose book (like BBD) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "poy", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 11 chapters = 12 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-poy.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const POY_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "poy");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PoyIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type PoyIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type PoyChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedPoy(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(POY_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ POY intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<PoyIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "poy" });
|
||||
await metaCol.insertOne({ book: "poy", data: intro });
|
||||
console.log(" 📖 POY book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "poy" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(POY_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<PoyIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "poy",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 11.json)
|
||||
const chaptersDir = path.join(POY_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<PoyChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "poy",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "poy" });
|
||||
console.log(` ✅ POY: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding The Perfection of Yoga...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedPoy(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* seed-pqpa.ts — Push Perfect Questions Perfect Answers JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* PQPA is a prose/dialogue book with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "pqpa", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 9 chapters + concluding words
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-pqpa.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const PQPA_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "pqpa");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PqpaIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type PqpaIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type PqpaChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedPqpa(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(PQPA_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ PQPA intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<PqpaIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "pqpa" });
|
||||
await metaCol.insertOne({ book: "pqpa", data: intro });
|
||||
console.log(" 📖 PQPA book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "pqpa" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introDir = path.join(PQPA_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<PqpaIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "pqpa",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 09.json)
|
||||
const chaptersDir = path.join(PQPA_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json") && f !== "concluding-words.json")
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<PqpaChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "pqpa",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
|
||||
// Concluding words
|
||||
const concludingPath = path.join(chaptersDir, "concluding-words.json");
|
||||
if (fs.existsSync(concludingPath)) {
|
||||
const cw = readJson<PqpaChapterJson>(concludingPath);
|
||||
docs.push({
|
||||
book: "pqpa",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: cw.title,
|
||||
type: cw.type || "conclusion",
|
||||
slug: "concluding-words",
|
||||
order: 9999,
|
||||
paragraphs: cw.paragraphs,
|
||||
});
|
||||
console.log(" 📝 Concluding words: 1");
|
||||
}
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "pqpa" });
|
||||
console.log(` ✅ PQPA: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Perfect Questions Perfect Answers...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedPqpa(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-rv.ts — Push Rāja-vidyā: The King of Knowledge JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-bbd.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* RV is a prose book (like BBD) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "rv", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 8 chapters = 9 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-rv.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const RV_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "rv");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RvIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type RvIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type RvChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedRv(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(RV_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ RV intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<RvIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "rv" });
|
||||
await metaCol.insertOne({ book: "rv", data: intro });
|
||||
console.log(" 📖 RV book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "rv" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(RV_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<RvIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "rv",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 08.json)
|
||||
const chaptersDir = path.join(RV_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<RvChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "rv",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "rv" });
|
||||
console.log(` ✅ RV: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Rāja-vidyā: The King of Knowledge...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedRv(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* seed-ssr.ts — Push The Science of Self-Realization JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-hkh.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* SSR is a large prose book with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Sub-chapters are stored with slugs like "1-1", "1-2", "2-1", etc.
|
||||
* Each sub-chapter JSON has parentChapter and subChapter fields used to
|
||||
* compute a stable ordering: parentChapter * 100 + subChapter.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "ssr", data: <intro.json> }
|
||||
* - prose_chapters → 6 intro segments + ~50 sub-chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-ssr.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const SSR_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ssr");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SsrIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: {
|
||||
number: number;
|
||||
title: string;
|
||||
subChapters: { slug: string; title: string }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type SsrIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type SsrChapterJson = {
|
||||
book: string;
|
||||
chapter: string; // "1-1", "2-3", etc.
|
||||
parentChapter: number;
|
||||
subChapter: number;
|
||||
title: string;
|
||||
parentTitle: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedSsr(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(SSR_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ SSR intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<SsrIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "ssr" });
|
||||
await metaCol.insertOne({ book: "ssr", data: intro });
|
||||
console.log(" 📖 SSR book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "ssr" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (6 total)
|
||||
const introSegmentFiles = [
|
||||
"front-pages.json",
|
||||
"dedication.json",
|
||||
"foreword.json",
|
||||
"introduction.json",
|
||||
"appreciations.json",
|
||||
"about-the-author.json",
|
||||
];
|
||||
|
||||
const introDir = path.join(SSR_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<SsrIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "ssr",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Sub-chapters (1-1.json, 1-2.json, 2-1.json, etc.)
|
||||
const chaptersDir = path.join(SSR_DIR, "chapters");
|
||||
let chapterCount = 0;
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort((a, b) => {
|
||||
// Sort by parentChapter then subChapter numerically
|
||||
const parseSlug = (name: string) => {
|
||||
const slug = name.replace(".json", "");
|
||||
const parts = slug.split("-");
|
||||
return {
|
||||
parent: parseInt(parts[0], 10),
|
||||
sub: parseInt(parts[1], 10),
|
||||
};
|
||||
};
|
||||
const aa = parseSlug(a);
|
||||
const bb = parseSlug(b);
|
||||
if (aa.parent !== bb.parent) return aa.parent - bb.parent;
|
||||
return aa.sub - bb.sub;
|
||||
});
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<SsrChapterJson>(path.join(chaptersDir, file));
|
||||
const order = ch.parentChapter * 100 + ch.subChapter;
|
||||
|
||||
docs.push({
|
||||
book: "ssr",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null, // null since chapter is a string "1-1" not an int
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: ch.chapter, // "1-1", "1-2", "2-1", etc.
|
||||
order,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
chapterCount++;
|
||||
}
|
||||
console.log(` 📖 Sub-chapters: ${chapterCount}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "ssr" });
|
||||
console.log(` ✅ SSR: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding The Science of Self-Realization...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedSsr(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* seed-te.ts — Push Two Essays JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-ej.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* Two Essays is a collection of 2 standalone essays using the NEW `essays`
|
||||
* collection (not `prose_chapters`).
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "te", data: <intro.json> }
|
||||
* - essays → 2 essay docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-te.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const TE_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "te");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TeIntroJson = {
|
||||
group: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
essays: { slug: string; title: string; order: number }[];
|
||||
};
|
||||
|
||||
type TeEssayJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
group: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedTe(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(TE_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ TE intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<TeIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "te" });
|
||||
await metaCol.insertOne({ book: "te", data: intro });
|
||||
console.log(" 📖 TE book meta inserted");
|
||||
|
||||
// ---- 2. Essays collection ----
|
||||
const essaysCol = db.collection("essays");
|
||||
await essaysCol.deleteMany({ group: "two-essays" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
const essaysDir = path.join(TE_DIR, "essays");
|
||||
if (!fs.existsSync(essaysDir)) {
|
||||
console.error("❌ TE essays directory not found at", essaysDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const essayFiles = fs
|
||||
.readdirSync(essaysDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of essayFiles) {
|
||||
const essay = readJson<TeEssayJson>(path.join(essaysDir, file));
|
||||
|
||||
docs.push({
|
||||
slug: essay.slug,
|
||||
title: essay.title,
|
||||
author: essay.author,
|
||||
year: essay.year,
|
||||
group: essay.group,
|
||||
order: essay.order,
|
||||
paragraphs: essay.paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📝 Essays found: ${docs.length}`);
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} essay documents...`);
|
||||
await insertBatched(essaysCol, docs);
|
||||
|
||||
const totalDocs = await essaysCol.countDocuments({ group: "two-essays" });
|
||||
console.log(` ✅ TE: ${totalDocs} essays in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Two Essays...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedTe(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* seed-tlc.ts — Push Teachings of Lord Caitanya JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-nod.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* TLC is a prose book (like NoD) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "tlc", data: <intro.json> }
|
||||
* - prose_chapters → 9 intro segments + 32 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-tlc.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const TLC_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "tlc");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TlcIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
parts: { number: number; title: string; chapters: number[] }[];
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type TlcIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type TlcChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
slug?: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedTlc(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(TLC_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ TLC intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<TlcIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "tlc" });
|
||||
await metaCol.insertOne({ book: "tlc", data: intro });
|
||||
console.log(" 📖 TLC book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "tlc" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introDir = path.join(TLC_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
let fallbackOrder = 0;
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<TlcIntroSegmentJson>(path.join(introDir, file));
|
||||
fallbackOrder++;
|
||||
docs.push({
|
||||
book: "tlc",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? fallbackOrder,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 32.json)
|
||||
const chaptersDir = path.join(TLC_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<TlcChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "tlc",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "tlc" });
|
||||
console.log(` ✅ TLC: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Teachings of Lord Caitanya...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedTlc(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* seed-ttp.ts — Push Transcendental Teachings of Prahlāda Mahārāja JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-poy.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* TTP is a prose book (like POY) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "ttp", data: <intro.json> }
|
||||
* - prose_chapters → 1 intro segment + 5 chapters = 6 docs
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-ttp.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const TTP_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "ttp");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TtpIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type TtpIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type TtpChapterJson = {
|
||||
book: string;
|
||||
chapter: number;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedTtp(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(TTP_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ TTP intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<TtpIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "ttp" });
|
||||
await metaCol.insertOne({ book: "ttp", data: intro });
|
||||
console.log(" 📖 TTP book meta inserted");
|
||||
|
||||
// ---- 2. Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "ttp" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments
|
||||
const introSegmentFiles = ["front-pages.json"];
|
||||
|
||||
const introDir = path.join(TTP_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<TtpIntroSegmentJson>(filePath);
|
||||
|
||||
docs.push({
|
||||
book: "ttp",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${docs.length}`);
|
||||
|
||||
// Chapters (01.json through 05.json)
|
||||
const chaptersDir = path.join(TTP_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<TtpChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "ttp",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "ttp" });
|
||||
console.log(` ✅ TTP: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Transcendental Teachings of Prahlāda Mahārāja...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedTtp(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Two Essays (1966) from other.html.
|
||||
|
||||
The book contains 2 essays:
|
||||
1. "Krishna The Reservoir Of Pleasure"
|
||||
2. "Who Is Crazy?"
|
||||
|
||||
These are plain English essays from 1966 — NO ScaGoudy encoding.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/te/intro.json — Book metadata
|
||||
lib/jsons/books/te/essays/krishna-reservoir-of-pleasure.json
|
||||
lib/jsons/books/te/essays/who-is-crazy.json
|
||||
|
||||
Usage:
|
||||
python3 scripts/te_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def clean_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, strip span class artifacts, preserve <i>, <b>, <br>."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS classes to SKIP entirely (ad block, structural headers, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
SKIP_CLASSES = {"p12", "p121", "p122", "p13", "p123", "p124", "p23", "p18"}
|
||||
|
||||
# CSS classes that contain essay body content
|
||||
BODY_CLASSES = {"p28", "p6", "p22", "p3"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_te_boundaries(all_paragraphs: list[Tag]) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices for Two Essays content.
|
||||
|
||||
Start: <p class="p12"><b>TWO ESSAYS</b></p>
|
||||
End: Next <p class="p12"> or <p class="p24"> or <p class="p34"> after our content,
|
||||
or just past the last essay paragraph.
|
||||
|
||||
Returns (start_idx, end_idx) — exclusive end.
|
||||
"""
|
||||
te_start = None
|
||||
te_end = None
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
css = get_css_class(p)
|
||||
text = p.get_text().strip().upper()
|
||||
|
||||
# Find the TWO ESSAYS header
|
||||
if css == "p12" and "TWO ESSAYS" in text:
|
||||
te_start = i
|
||||
continue
|
||||
|
||||
# Once we've found the start, look for the end
|
||||
if te_start is not None and te_end is None:
|
||||
# A new p12 header means we've left Two Essays
|
||||
if css == "p12" and i > te_start:
|
||||
te_end = i
|
||||
break
|
||||
# Collier end markers
|
||||
if css in ("p24", "p34"):
|
||||
te_end = i
|
||||
break
|
||||
|
||||
if te_start is None:
|
||||
raise ValueError("Could not find TWO ESSAYS header in HTML")
|
||||
|
||||
# If we didn't find an explicit end marker, scan for the last body paragraph
|
||||
if te_end is None:
|
||||
# Walk backwards from the end to find the last relevant paragraph
|
||||
for i in range(len(all_paragraphs) - 1, te_start, -1):
|
||||
css = get_css_class(all_paragraphs[i])
|
||||
if css in BODY_CLASSES:
|
||||
te_end = i + 1
|
||||
break
|
||||
if te_end is None:
|
||||
te_end = len(all_paragraphs)
|
||||
|
||||
return te_start, te_end
|
||||
|
||||
|
||||
def find_essay_headings(
|
||||
all_paragraphs: list[Tag], start: int, end: int
|
||||
) -> list[tuple[int, str]]:
|
||||
"""Find p27 headings within the Two Essays range.
|
||||
|
||||
Returns list of (paragraph_index, title_text).
|
||||
"""
|
||||
headings = []
|
||||
for i in range(start, end):
|
||||
p = all_paragraphs[i]
|
||||
css = get_css_class(p)
|
||||
if css == "p27":
|
||||
title = p.get_text().strip()
|
||||
headings.append((i, title))
|
||||
return headings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paragraph extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_essay_paragraphs(
|
||||
all_paragraphs: list[Tag], start: int, end: int
|
||||
) -> list[str]:
|
||||
"""Extract body paragraphs from the given range.
|
||||
|
||||
Includes: p28, p6, p22, p3
|
||||
Skips: SKIP_CLASSES, p27 (headings), empty paragraphs
|
||||
"""
|
||||
paragraphs = []
|
||||
|
||||
for i in range(start, end):
|
||||
p = all_paragraphs[i]
|
||||
css = get_css_class(p)
|
||||
|
||||
# Skip classes we don't want
|
||||
if css in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip headings (already handled)
|
||||
if css == "p27":
|
||||
continue
|
||||
|
||||
# Only accept body classes
|
||||
if css not in BODY_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
html = clean_html_content(p)
|
||||
if html and html != " ":
|
||||
paragraphs.append(html)
|
||||
|
||||
return paragraphs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_te(soup: BeautifulSoup) -> dict:
|
||||
"""Extract Two Essays from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"essays": list of essay dicts
|
||||
"essay_listing": list of {slug, title, order} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find Two Essays boundaries
|
||||
te_start, te_end = find_te_boundaries(all_paragraphs)
|
||||
print(f" Two Essays content: paragraphs {te_start} to {te_end}")
|
||||
print(f" Total paragraphs in range: {te_end - te_start}")
|
||||
|
||||
# Step 2: Find essay headings (p27)
|
||||
headings = find_essay_headings(all_paragraphs, te_start, te_end)
|
||||
print(f" Found {len(headings)} essay headings")
|
||||
for pos, title in headings:
|
||||
print(f" paragraph {pos}: {title}")
|
||||
|
||||
if len(headings) != 2:
|
||||
print(f" WARNING: Expected 2 essay headings, found {len(headings)}")
|
||||
|
||||
# Step 3: Extract essays
|
||||
essays = []
|
||||
|
||||
essay_configs = [
|
||||
{
|
||||
"slug": "krishna-reservoir-of-pleasure",
|
||||
"title": "Krishna The Reservoir Of Pleasure",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"slug": "who-is-crazy",
|
||||
"title": "Who Is Crazy?",
|
||||
"order": 2,
|
||||
},
|
||||
]
|
||||
|
||||
for idx, (heading_pos, heading_title) in enumerate(headings):
|
||||
config = essay_configs[idx] if idx < len(essay_configs) else None
|
||||
|
||||
# Content starts after the heading
|
||||
content_start = heading_pos + 1
|
||||
|
||||
# Content ends at next heading or te_end
|
||||
if idx + 1 < len(headings):
|
||||
content_end = headings[idx + 1][0]
|
||||
else:
|
||||
content_end = te_end
|
||||
|
||||
paragraphs = extract_essay_paragraphs(
|
||||
all_paragraphs, content_start, content_end
|
||||
)
|
||||
|
||||
if config:
|
||||
slug = config["slug"]
|
||||
title = config["title"]
|
||||
order = config["order"]
|
||||
else:
|
||||
# Fallback for unexpected essays
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", heading_title.lower()).strip("-")
|
||||
title = heading_title
|
||||
order = idx + 1
|
||||
|
||||
essay = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1966,
|
||||
"group": "two-essays",
|
||||
"order": order,
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
essays.append(essay)
|
||||
|
||||
# Step 4: Build essay listing for intro.json
|
||||
essay_listing = [
|
||||
{"slug": e["slug"], "title": e["title"], "order": e["order"]} for e in essays
|
||||
]
|
||||
|
||||
return {
|
||||
"essays": essays,
|
||||
"essay_listing": essay_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "te")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Two Essays (1966)...\n")
|
||||
result = extract_te(soup)
|
||||
|
||||
essays = result["essays"]
|
||||
essay_listing = result["essay_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"group": "two-essays",
|
||||
"title": "Two Essays",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1966,
|
||||
"description": "Two foundational essays: 'Krishna The Reservoir Of Pleasure' and 'Who Is Crazy?'",
|
||||
"essays": essay_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write essay files ---
|
||||
print(f"\n--- Essays ---")
|
||||
for essay in essays:
|
||||
filepath = os.path.join(output_base, "essays", f"{essay['slug']}.json")
|
||||
write_json(essay, filepath)
|
||||
print(
|
||||
f" {essay['order']}. {essay['title']}: "
|
||||
f"{len(essay['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_paras = sum(len(e["paragraphs"]) for e in essays)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Essays: {len(essays)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
for e in essays:
|
||||
print(f" {e['order']}. {e['title']}: {len(e['paragraphs'])} paragraphs")
|
||||
if essays:
|
||||
print(f" Avg paras/essay: {total_paras / len(essays):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
for essay in essays:
|
||||
print(f"\n--- Sample: {essay['title']} ---")
|
||||
for i, p in enumerate(essay["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check both essays present
|
||||
expected_slugs = {"krishna-reservoir-of-pleasure", "who-is-crazy"}
|
||||
actual_slugs = {e["slug"] for e in essays}
|
||||
missing = expected_slugs - actual_slugs
|
||||
extra = actual_slugs - expected_slugs
|
||||
if missing:
|
||||
print(f" ⚠ Missing essays: {missing}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra essays: {extra}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ Both essays present")
|
||||
|
||||
# Check empty essays
|
||||
empty_essays = [e["slug"] for e in essays if len(e["paragraphs"]) == 0]
|
||||
if empty_essays:
|
||||
print(f" ⚠ Empty essays (0 paragraphs): {empty_essays}")
|
||||
else:
|
||||
print(f" ✅ No empty essays")
|
||||
|
||||
# Check paragraph counts are reasonable
|
||||
for e in essays:
|
||||
count = len(e["paragraphs"])
|
||||
if count < 5:
|
||||
print(
|
||||
f" ⚠ Essay '{e['slug']}' has only {count} paragraphs (suspiciously low)"
|
||||
)
|
||||
elif count > 100:
|
||||
print(f" ⚠ Essay '{e['slug']}' has {count} paragraphs (suspiciously high)")
|
||||
else:
|
||||
print(f" ✅ Essay '{e['slug']}' has {count} paragraphs (reasonable)")
|
||||
|
||||
# Check for residual span class artifacts
|
||||
span_count = 0
|
||||
for e in essays:
|
||||
for p in e["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check no ScaGoudy encoding present (these are plain English essays)
|
||||
# But verify key words appear as plain English (not IAST)
|
||||
all_text = " ".join(" ".join(e["paragraphs"][:10]) for e in essays)
|
||||
if "Krishna" in all_text:
|
||||
print(f" ✅ 'Krishna' found (plain English, as expected for 1966)")
|
||||
else:
|
||||
print(f" ⚠ 'Krishna' not found in text")
|
||||
|
||||
if "Bhagavad Gita" in all_text or "Bhagavad-Gita" in all_text:
|
||||
print(f" ✅ 'Bhagavad Gita' found (plain English)")
|
||||
|
||||
# Check for ad content leaking in
|
||||
ad_markers = ["ISKCON", "26 2nd Ave", "SRIMAD-BHAGAWATAM", "$3.25", "$16.80"]
|
||||
leaked_ads = []
|
||||
for e in essays:
|
||||
for p in e["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for marker in ad_markers:
|
||||
if marker in plain:
|
||||
leaked_ads.append((e["slug"], marker, plain[:80]))
|
||||
break
|
||||
if leaked_ads:
|
||||
print(f" ⚠ {len(leaked_ads)} paragraphs may contain ad content:")
|
||||
for slug, marker, preview in leaked_ads[:3]:
|
||||
print(f" [{slug}] matched '{marker}': {preview}...")
|
||||
else:
|
||||
print(f" ✅ No ad content detected in essays")
|
||||
|
||||
# Check for SKIP_CLASSES content leaking in
|
||||
# (p18 paragraphs contain ad text like "BLISS—what was once only a word...")
|
||||
bliss_check = False
|
||||
for e in essays:
|
||||
for p in e["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if plain.startswith("BLISS") or "SOUNDS OF KRISHNA CONSCIOUSNESS" in plain:
|
||||
bliss_check = True
|
||||
break
|
||||
if bliss_check:
|
||||
print(f" ⚠ Ad text ('BLISS...') leaked into essay paragraphs")
|
||||
else:
|
||||
print(f" ✅ Ad text properly excluded")
|
||||
|
||||
# Show last paragraph of each essay
|
||||
print(f"\n--- Last Paragraph of Each Essay ---")
|
||||
for e in essays:
|
||||
if e["paragraphs"]:
|
||||
last = re.sub(r"<[^>]+>", "", e["paragraphs"][-1])[:150]
|
||||
print(f" {e['title']}:")
|
||||
print(f" ...{last}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,868 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Teachings of Lord Caitanya (1968 Edition) from other.html.
|
||||
|
||||
The book has 9 intro segments, 32 chapters in 2 parts.
|
||||
|
||||
IMPORTANT: This is the 1968 edition with plain English transliterations.
|
||||
NO ScaGoudy font decoding is needed.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/tlc/intro.json — Book metadata
|
||||
lib/jsons/books/tlc/intro/*.json — Intro segments
|
||||
lib/jsons/books/tlc/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/tlc_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for TLC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Bookmark class — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p6", "p44", "p18", "p30"])
|
||||
|
||||
# Section heading (bold) — "Chapter One", "Preface", etc.
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
|
||||
# Chapter title (bold) — "Teachings to Rupa Goswami", etc.
|
||||
CHAPTER_TITLE_CLASS = "p27"
|
||||
|
||||
# Part header class — "PART I", "PART II"
|
||||
PART_HEADER_CLASS = "p34"
|
||||
|
||||
# Title page / copyright text classes (in intro area)
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
COPYRIGHT_CLASS = "p32"
|
||||
|
||||
# Italic verse quote and attribution
|
||||
VERSE_QUOTE_CLASS = "p39"
|
||||
VERSE_ATTRIBUTION_CLASS = "p40"
|
||||
|
||||
# Photo/painting credits
|
||||
CREDITS_CLASS = "p41"
|
||||
|
||||
# Attribution text ("The Publishers")
|
||||
ATTRIBUTION_CLASS = "p42"
|
||||
|
||||
# Section title bold text
|
||||
SECTION_TITLE_CLASS = "p43"
|
||||
|
||||
# Numbered items (in Mission section)
|
||||
NUMBERED_ITEM_CLASS = "p24"
|
||||
|
||||
# Italic text (e.g., "Hari! Hari!")
|
||||
ITALIC_TEXT_CLASS = "p19"
|
||||
|
||||
# Classes to skip entirely (empty/decorative)
|
||||
SKIP_CLASSES = frozenset(["p9", "p15"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INTRO_SEGMENTS = [
|
||||
{
|
||||
"slug": "front-flap",
|
||||
"title": "Front Flap & Pages",
|
||||
"order": 1,
|
||||
"bookmark_text": "TLC-1968 Front flap & pages",
|
||||
},
|
||||
{
|
||||
"slug": "the-publishers",
|
||||
"title": "The Publishers",
|
||||
"order": 2,
|
||||
"bookmark_text": "TLC-1968 The Publishers",
|
||||
},
|
||||
{
|
||||
"slug": "dedication",
|
||||
"title": "Dedication",
|
||||
"order": 3,
|
||||
"bookmark_text": "TLC-1968 DEDICATION",
|
||||
},
|
||||
{
|
||||
"slug": "acknowledgement",
|
||||
"title": "Acknowledgement",
|
||||
"order": 4,
|
||||
"bookmark_text": "TLC-1968 ACKNOWLEDGEMENT",
|
||||
},
|
||||
{
|
||||
"slug": "the-author",
|
||||
"title": "The Author",
|
||||
"order": 5,
|
||||
"bookmark_text": "TLC-1968 The Author",
|
||||
},
|
||||
{
|
||||
"slug": "preface",
|
||||
"title": "Preface",
|
||||
"order": 6,
|
||||
"bookmark_text": "TLC-1968 Preface",
|
||||
},
|
||||
{
|
||||
"slug": "prologue",
|
||||
"title": "Prologue",
|
||||
"order": 7,
|
||||
"bookmark_text": "TLC-1968 Prologue",
|
||||
},
|
||||
{
|
||||
"slug": "mission",
|
||||
"title": "Lord Chaitanya's Mission",
|
||||
"order": 8,
|
||||
"bookmark_text": "TLC-1968 Mission",
|
||||
},
|
||||
{
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"order": 9,
|
||||
"bookmark_text": "TLC-1968 Intoduction", # Note: typo in original
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML content helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def clean_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, strip span artifacts, preserve formatting tags.
|
||||
|
||||
IMPORTANT: No ScaGoudy decoding — this is the 1968 plain English edition.
|
||||
"""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def get_plain_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag (no decoding needed for 1968 edition)."""
|
||||
return tag.get_text().strip()
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_tlc_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the TLC content.
|
||||
|
||||
Start: <p class="p12"> containing "Teachings of Lord Caitanya"
|
||||
End: next <p class="p12"> after start, or a dashed line followed by p3,
|
||||
or end of file
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = get_plain_text(p)
|
||||
if "Teachings of Lord Caitanya" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after TLC start = end of TLC
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Teachings of Lord Caitanya' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Also check for the dashed-line boundary before end_idx
|
||||
# The last content is followed by a dashed line, then the next book starts
|
||||
for i in range(start_idx + 1, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
text = get_plain_text(p)
|
||||
# A dashed line in a p6 paragraph followed by a p3 paragraph = end boundary
|
||||
if cls == "p6" and is_dash_line(text):
|
||||
# Check if next non-empty paragraph is p3 (start of next book)
|
||||
for j in range(i + 1, min(i + 5, end_idx)):
|
||||
next_cls = get_css_class(all_paragraphs[j])
|
||||
if next_cls == "p3":
|
||||
end_idx = i # End before the dashed line
|
||||
break
|
||||
if end_idx == i:
|
||||
break
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all p13 bookmark tags in the TLC range.
|
||||
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
text = get_plain_text(p)
|
||||
if "TLC" in text:
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str) -> tuple:
|
||||
"""Parse a chapter bookmark like 'TLC 1-1968: Teachings to Rupa Goswami'.
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"TLC\s+(\d+)-1968:\s*(.+)", text.strip())
|
||||
if m:
|
||||
return int(m.group(1)), m.group(2).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str) -> str:
|
||||
"""Parse an intro bookmark like 'TLC-1968 Preface'.
|
||||
|
||||
Returns the segment name or None if not an intro bookmark.
|
||||
"""
|
||||
m = re.match(r"TLC-1968\s+(.+)", text.strip())
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_intro: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the TLC content.
|
||||
No ScaGoudy decoding is applied.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = get_plain_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Part headers (p34) — include as bold paragraph
|
||||
if cls == PART_HEADER_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section heading (p31) — include as paragraph
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Chapter title (p27) — include as bold paragraph
|
||||
if cls == CHAPTER_TITLE_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse quote (p39) — format as centered italic div
|
||||
if cls == VERSE_QUOTE_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
# Strip inner <i></i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
result.append(f'<div style="text-align:center"><i>{stripped}</i></div>')
|
||||
continue
|
||||
|
||||
# Verse attribution (p40) — format as centered italic div
|
||||
if cls == VERSE_ATTRIBUTION_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
result.append(f'<div style="text-align:center"><i>{stripped}</i></div>')
|
||||
continue
|
||||
|
||||
# Title page / copyright text (in intro area)
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html and not is_dash_line(get_plain_text(p)):
|
||||
# Detect Sanskrit verses disguised as title-page paragraphs
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Copyright text (p32)
|
||||
if cls == COPYRIGHT_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Photo/painting credits (p41)
|
||||
if cls == CREDITS_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Attribution text (p42) — "The Publishers"
|
||||
if cls == ATTRIBUTION_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section title bold text (p43)
|
||||
if cls == SECTION_TITLE_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Numbered items (p24) — include as regular paragraphs
|
||||
if cls == NUMBERED_ITEM_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Italic text like "Hari! Hari!" (p19)
|
||||
if cls == ITALIC_TEXT_CLASS:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6, p44, p18, p30)
|
||||
if cls in BODY_CLASSES:
|
||||
html = clean_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = clean_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_tlc(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Teachings of Lord Caitanya from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"parts": list of part assignment dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find TLC boundaries
|
||||
tlc_start, tlc_end = find_tlc_boundaries(all_paragraphs)
|
||||
print(f" TLC content: paragraphs {tlc_start} to {tlc_end}")
|
||||
print(f" Total paragraphs in range: {tlc_end - tlc_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, tlc_start, tlc_end)
|
||||
print(f" Found {len(bookmarks)} TLC bookmarks")
|
||||
|
||||
# Step 3: Categorize bookmarks into intro vs chapter
|
||||
intro_bookmarks = [] # (para_idx, bookmark_text)
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
else:
|
||||
intro_name = parse_intro_bookmark(text)
|
||||
if intro_name:
|
||||
intro_bookmarks.append((pos, text))
|
||||
|
||||
print(f" Intro bookmarks: {len(intro_bookmarks)}")
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
|
||||
# Step 4: Track PART assignments by scanning for p34 tags
|
||||
part_assignments = {} # chapter_num -> part_num
|
||||
parts_data = []
|
||||
|
||||
part_positions = [] # (para_idx, part_title)
|
||||
for i in range(tlc_start, tlc_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == PART_HEADER_CLASS:
|
||||
text = get_plain_text(p)
|
||||
if text:
|
||||
part_positions.append((i, text))
|
||||
|
||||
print(f" Part headers found: {len(part_positions)}")
|
||||
for pos, title in part_positions:
|
||||
print(f" Paragraph ~{pos}: {title}")
|
||||
|
||||
# Map part positions to part numbers
|
||||
part_number_map = {}
|
||||
for pos, title in part_positions:
|
||||
title_upper = title.upper().strip()
|
||||
if "II" in title_upper and "III" not in title_upper:
|
||||
part_number_map[pos] = 2
|
||||
elif "I" in title_upper:
|
||||
part_number_map[pos] = 1
|
||||
|
||||
# Assign parts to chapters based on position
|
||||
sorted_part_positions = sorted(part_number_map.keys())
|
||||
for ch_pos, ch_num, ch_title in chapter_bookmarks:
|
||||
assigned_part = None
|
||||
for pp in sorted_part_positions:
|
||||
if pp < ch_pos:
|
||||
assigned_part = part_number_map[pp]
|
||||
else:
|
||||
break
|
||||
if assigned_part:
|
||||
part_assignments[ch_num] = assigned_part
|
||||
|
||||
# Build parts_data
|
||||
for part_num in sorted(set(part_assignments.values())):
|
||||
chs = sorted([ch for ch, pn in part_assignments.items() if pn == part_num])
|
||||
part_title = f"Part {'I' if part_num == 1 else 'II'}"
|
||||
parts_data.append(
|
||||
{
|
||||
"number": part_num,
|
||||
"title": part_title,
|
||||
"chapters": chs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 5: Collect all section start positions for boundary detection
|
||||
all_section_starts = sorted(
|
||||
[pos for pos, _ in intro_bookmarks] + [pos for pos, _, _ in chapter_bookmarks]
|
||||
)
|
||||
|
||||
# Step 6: Extract intro segments
|
||||
intro_segments = []
|
||||
for seg_def in INTRO_SEGMENTS:
|
||||
# Find matching bookmark
|
||||
bookmark_pos = None
|
||||
for pos, btext in intro_bookmarks:
|
||||
if btext.strip() == seg_def["bookmark_text"]:
|
||||
bookmark_pos = pos
|
||||
break
|
||||
|
||||
if bookmark_pos is None:
|
||||
print(f" WARNING: Could not find bookmark for '{seg_def['slug']}'")
|
||||
continue
|
||||
|
||||
# Content starts after the bookmark
|
||||
content_start = bookmark_pos + 1
|
||||
|
||||
# Content ends at the next bookmark or part header or tlc_end
|
||||
content_end = tlc_end
|
||||
for s in all_section_starts:
|
||||
if s > bookmark_pos:
|
||||
content_end = s
|
||||
break
|
||||
|
||||
# Also check if a PART header comes before the next bookmark
|
||||
for pp in sorted_part_positions:
|
||||
if pp > bookmark_pos and pp < content_end:
|
||||
content_end = pp
|
||||
break
|
||||
|
||||
paragraphs = extract_paragraphs(
|
||||
all_paragraphs, content_start, content_end, is_intro=True
|
||||
)
|
||||
|
||||
# Use the corrected title (e.g., "Introduction" not "Intoduction")
|
||||
seg = {
|
||||
"slug": seg_def["slug"],
|
||||
"title": seg_def["title"],
|
||||
"type": "intro-segment",
|
||||
"book": "tlc",
|
||||
"order": seg_def["order"],
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
intro_segments.append(seg)
|
||||
|
||||
# Step 7: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter bookmark or tlc_end
|
||||
if idx + 1 < len(chapter_bookmarks):
|
||||
content_end = chapter_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = tlc_end
|
||||
|
||||
# For the PART header: if a PART header appears right before this
|
||||
# chapter's bookmark, include it in this chapter's content range.
|
||||
actual_start = content_start
|
||||
for pp in sorted_part_positions:
|
||||
if pp < ch_pos and pp >= content_start - 5:
|
||||
# Check that there's no other bookmark between pp and ch_pos
|
||||
has_other_bookmark = False
|
||||
for bpos, _ in bookmarks:
|
||||
if bpos > pp and bpos < ch_pos:
|
||||
has_other_bookmark = True
|
||||
break
|
||||
if not has_other_bookmark:
|
||||
actual_start = pp
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, actual_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "tlc",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 8: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"parts": parts_data,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "tlc")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Teachings of Lord Caitanya (1968 Edition)...\n")
|
||||
result = extract_tlc(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
parts = result["parts"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "tlc",
|
||||
"title": "Teachings of Lord Caitanya",
|
||||
"subtitle": "A Treatise on Factual Spiritual Life",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1968,
|
||||
"description": "The sublime life and teachings of Lord Caitanya Mahāprabhu, the most munificent incarnation of the Supreme Lord.",
|
||||
"totalChapters": 32,
|
||||
"parts": parts,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
part_info = ""
|
||||
for p in parts:
|
||||
if ch["chapter"] in p["chapters"]:
|
||||
part_info = f" [Part {p['number']}]"
|
||||
break
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}{part_info}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Parts: {len(parts)}")
|
||||
for p in parts:
|
||||
print(
|
||||
f" Part {p['number']} ({p['title']}): chapters {p['chapters'][0]}-{p['chapters'][-1]} ({len(p['chapters'])} chapters)"
|
||||
)
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:4]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(f"\n--- Sample: Chapter {chapters[-1]['chapter']} (last) ---")
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if intro_segments:
|
||||
print(f"\n--- Sample: First intro segment ({intro_segments[0]['title']}) ---")
|
||||
first_intro = intro_segments[0]
|
||||
for i, p in enumerate(first_intro["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 32 chapters present
|
||||
expected_chapters = set(range(1, 33))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 32 chapters present")
|
||||
|
||||
# Check all 9 intro segments present
|
||||
expected_intros = {
|
||||
"front-flap",
|
||||
"the-publishers",
|
||||
"dedication",
|
||||
"acknowledgement",
|
||||
"the-author",
|
||||
"preface",
|
||||
"prologue",
|
||||
"mission",
|
||||
"introduction",
|
||||
}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 9 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check part assignments
|
||||
all_part_chapters = set()
|
||||
for p in parts:
|
||||
all_part_chapters.update(p["chapters"])
|
||||
unassigned = [
|
||||
ch["chapter"] for ch in chapters if ch["chapter"] not in all_part_chapters
|
||||
]
|
||||
if unassigned:
|
||||
print(f" ⚠ Chapters not assigned to any part: {unassigned}")
|
||||
else:
|
||||
print(f" ✅ All chapters assigned to parts")
|
||||
|
||||
# Verify Part I has chapters 1-16 and Part II has chapters 17-32
|
||||
for p in parts:
|
||||
if p["number"] == 1:
|
||||
expected = list(range(1, 17))
|
||||
if p["chapters"] == expected:
|
||||
print(f" ✅ Part I correctly has chapters 1-16")
|
||||
else:
|
||||
print(f" ⚠ Part I has chapters {p['chapters']}, expected {expected}")
|
||||
elif p["number"] == 2:
|
||||
expected = list(range(17, 33))
|
||||
if p["chapters"] == expected:
|
||||
print(f" ✅ Part II correctly has chapters 17-32")
|
||||
else:
|
||||
print(f" ⚠ Part II has chapters {p['chapters']}, expected {expected}")
|
||||
|
||||
# Verify no ScaGoudy artifacts (there shouldn't be any for 1968 edition)
|
||||
# Look for common ScaGoudy encoded words that would indicate wrongly applied decoding
|
||||
print(f"\n--- 1968 Edition Check ---")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:10])
|
||||
if "Krishna" in ch1_text:
|
||||
print(f" ✅ 'Krishna' found in Chapter 1 (plain English as expected)")
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' (IAST) found in Chapter 1 — unexpected for 1968 edition"
|
||||
)
|
||||
if "Goswami" in ch1_text or "Rupa Goswami" in ch1_text:
|
||||
print(f" ✅ 'Goswami' found (plain English as expected)")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,641 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Transcendental Teachings of Prahlāda Mahārāja (TTP) from other.html.
|
||||
|
||||
The book has 1 intro segment (front-pages) and 5 chapters.
|
||||
There are NO p13 bookmarks — chapters are detected via p27 headings.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/ttp/intro.json — Book metadata
|
||||
lib/jsons/books/ttp/intro/*.json — Intro segments
|
||||
lib/jsons/books/ttp/chapters/*.json — Chapters
|
||||
|
||||
Usage:
|
||||
python3 scripts/ttp_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for TTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Chapter heading class (used as chapter boundary since no p13 bookmarks)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Body paragraph classes (all treated as paragraphs)
|
||||
BODY_CLASSES = frozenset(["p48", "p6", "p99", "p98", "p71"])
|
||||
|
||||
# Front-page specific classes to include
|
||||
FRONT_PAGE_CLASSES = frozenset(["p69", "p76", "p18"])
|
||||
|
||||
# Classes to skip entirely
|
||||
SKIP_CLASSES = frozenset(["p9", "p15", "p70"])
|
||||
|
||||
# Chapter titles mapping (proper IAST decoded)
|
||||
CHAPTER_TITLES = {
|
||||
1: "The Dearmost Person",
|
||||
2: "Move on to the Perfect Goal",
|
||||
3: "The Highest Welfare Activity",
|
||||
4: "Cutting Off Ties of Affection",
|
||||
5: "Prayers by Prahlāda Mahārāja",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_ttp_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the TTP content.
|
||||
|
||||
Start: <p class="p12"> containing "Transcendental Teachings of Prahläda"
|
||||
End: next <p class="p12"> after start
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = p.get_text().strip()
|
||||
if "Transcendental Teachings of Prahl" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after TTP start = end of TTP
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print(
|
||||
"ERROR: Could not find 'Transcendental Teachings of Prahlāda' header in other.html!"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_chapter_headings(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all chapter headings (p27) in the TTP range.
|
||||
|
||||
Parses heading text with pattern: "N.Title" or "N. Title"
|
||||
Returns list of (paragraph_index, chapter_number, chapter_title).
|
||||
"""
|
||||
headings = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != CHAPTER_HEADING_CLASS:
|
||||
continue
|
||||
|
||||
text = decode_text(p)
|
||||
# Remove bold tags from text content
|
||||
text = re.sub(r"</?b>", "", text).strip()
|
||||
|
||||
# Try to parse chapter number and title
|
||||
m = re.match(r"(\d+)\s*[./]\s*(.+)", text)
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
ch_title_raw = m.group(2).strip()
|
||||
# Strip surrounding quotes if present
|
||||
ch_title_raw = ch_title_raw.strip('"').strip('"').strip('"')
|
||||
# Use pre-defined proper IAST titles if available
|
||||
ch_title = CHAPTER_TITLES.get(ch_num, ch_title_raw)
|
||||
headings.append((i, ch_num, ch_title))
|
||||
|
||||
return headings
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter or intro segment).
|
||||
|
||||
Handles all CSS classes within the TTP content.
|
||||
Detects italic+br verses in any body class → centered divs.
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip chapter headings (p27) — they are boundaries, not content
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Front-page specific classes
|
||||
if is_front_pages and cls in FRONT_PAGE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p48, p6, p99, p98, p71)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
# This is a verse — wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Front-page classes even outside is_front_pages context
|
||||
if cls in FRONT_PAGE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_ttp(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire TTP book from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find TTP boundaries
|
||||
ttp_start, ttp_end = find_ttp_boundaries(all_paragraphs)
|
||||
print(f" TTP content: paragraphs {ttp_start} to {ttp_end}")
|
||||
print(f" Total paragraphs in range: {ttp_end - ttp_start}")
|
||||
|
||||
# Step 2: Find all chapter headings (p27)
|
||||
chapter_headings = find_chapter_headings(all_paragraphs, ttp_start, ttp_end)
|
||||
print(f" Found {len(chapter_headings)} chapter headings")
|
||||
for pos, ch_num, ch_title in chapter_headings:
|
||||
print(f" [chapter {ch_num}] paragraph {pos}: {ch_title}")
|
||||
|
||||
# Step 3: Extract front pages
|
||||
# From book start (ttp_start) to first chapter heading
|
||||
intro_segments = []
|
||||
|
||||
if chapter_headings:
|
||||
front_pages_end = chapter_headings[0][0]
|
||||
else:
|
||||
front_pages_end = ttp_end
|
||||
|
||||
front_pages_paragraphs = extract_paragraphs(
|
||||
all_paragraphs, ttp_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
intro_segments.append(
|
||||
{
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "ttp",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
# Step 4: Extract chapters
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_headings):
|
||||
# Content starts after the heading
|
||||
content_start = ch_pos + 1
|
||||
|
||||
# Content ends at next chapter heading or ttp_end
|
||||
if idx + 1 < len(chapter_headings):
|
||||
content_end = chapter_headings[idx + 1][0]
|
||||
else:
|
||||
content_end = ttp_end
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "ttp",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 5: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "ttp")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Transcendental Teachings of Prahlāda Mahārāja...\n")
|
||||
result = extract_ttp(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "ttp",
|
||||
"title": "Transcendental Teachings of Prahlāda Mahārāja",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1972,
|
||||
"description": "The profound spiritual teachings of the great devotee Prahlāda Mahārāja from the Śrīmad-Bhāgavatam.",
|
||||
"totalChapters": len(chapters),
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Front Pages ---")
|
||||
if intro_segments:
|
||||
front = intro_segments[0]
|
||||
for i, p in enumerate(front["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters:
|
||||
print(f"\n--- Sample: Chapter 1 ({chapters[0]['title']}) ---")
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if chapters and len(chapters) > 1:
|
||||
print(
|
||||
f"\n--- Sample: Chapter {chapters[-1]['chapter']} ({chapters[-1]['title']}) ---"
|
||||
)
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:100]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 5 chapters present
|
||||
expected_chapters = set(range(1, 6))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 5 chapters present")
|
||||
|
||||
# Check all 1 intro segment present
|
||||
expected_intros = {"front-pages"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 1 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments: {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding
|
||||
print(f"\n--- ScaGoudy Decoding Check ---")
|
||||
all_text = ""
|
||||
for ch in chapters:
|
||||
all_text += " ".join(ch["paragraphs"][:20])
|
||||
for seg in intro_segments:
|
||||
all_text += " ".join(seg["paragraphs"][:10])
|
||||
|
||||
if "Kṛṣṇa" in all_text:
|
||||
print(f" ✅ 'Kṛṣṇa' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Kṛṣṇa' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prahlāda" in all_text or "Prahlād" in all_text:
|
||||
print(f" ✅ 'Prahlāda' found (ScaGoudy properly decoded)")
|
||||
else:
|
||||
print(f" ⚠ 'Prahlāda' not found — ScaGoudy decoding may have failed")
|
||||
|
||||
if "Prabhupāda" in all_text:
|
||||
print(f" ✅ 'Prabhupāda' found (ScaGoudy properly decoded)")
|
||||
|
||||
# Check for un-decoded ScaGoudy remnants
|
||||
scagoudy_remnants = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if "Kåñëa" in p or "Prabhupäda" in p:
|
||||
scagoudy_remnants += 1
|
||||
if scagoudy_remnants == 0:
|
||||
print(f" ✅ No ScaGoudy remnants detected")
|
||||
else:
|
||||
print(f" ⚠ {scagoudy_remnants} paragraphs with ScaGoudy remnants")
|
||||
|
||||
# Check for centered verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
if verse_block_count > 0:
|
||||
print(f" ✅ {verse_block_count} centered Sanskrit verse blocks found")
|
||||
else:
|
||||
print(f" ⚠ No centered verse blocks found — verse grouping may have failed")
|
||||
|
||||
# Check for residual span tags
|
||||
span_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if 'class="' in p and "<span" in p:
|
||||
span_count += 1
|
||||
if span_count == 0:
|
||||
print(f" ✅ No residual span class artifacts found")
|
||||
else:
|
||||
print(f" ⚠ {span_count} paragraphs still contain span class artifacts")
|
||||
|
||||
# Check for dash-line paragraphs that shouldn't be there
|
||||
dash_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if is_dash_line(plain):
|
||||
dash_count += 1
|
||||
if dash_count == 0:
|
||||
print(f" ✅ No dashed-line paragraphs in output")
|
||||
else:
|
||||
print(f" ⚠ {dash_count} dashed-line paragraphs found in output")
|
||||
|
||||
# Show a verse block sample
|
||||
print(f"\n--- Sample: Verse Block ---")
|
||||
found_verse = False
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
found_verse = True
|
||||
break
|
||||
if found_verse:
|
||||
break
|
||||
if not found_verse:
|
||||
for seg in intro_segments:
|
||||
for p in seg["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" {preview}...")
|
||||
found_verse = True
|
||||
break
|
||||
if found_verse:
|
||||
break
|
||||
if not found_verse:
|
||||
print(f" (no verse blocks found)")
|
||||
|
||||
# Chapter title decoding check
|
||||
print(f"\n--- Chapter Title Decoding Check ---")
|
||||
for ch in chapters:
|
||||
title = ch["title"]
|
||||
if "å" in title or "ë" in title or "ñ" in title or "ä" in title:
|
||||
print(f" ⚠ Chapter {ch['chapter']} title has un-decoded ScaGoudy: {title}")
|
||||
else:
|
||||
print(f" ✅ Ch {ch['chapter']}: {title}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user