feat: letters added
This commit is contained in:
+175
-12
@@ -27,7 +27,7 @@ The original RTF files use **custom font encodings** (Indevr, ScaGoudy, Inbenr/I
|
|||||||
```
|
```
|
||||||
spsays-training/
|
spsays-training/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── page.tsx → Landing page with Logo, links to Books/Essays/Glossary/RTFs
|
│ ├── page.tsx → Landing page with Logo, links to Books/Essays/Journals/Glossary/Other Documents/RTFs
|
||||||
│ ├── layout.tsx → ThemeProvider + TooltipProvider + PasswordGate wrapper
|
│ ├── layout.tsx → ThemeProvider + TooltipProvider + PasswordGate wrapper
|
||||||
│ ├── globals.css → Vintage color palette + reading settings CSS
|
│ ├── globals.css → Vintage color palette + reading settings CSS
|
||||||
│ ├── books/
|
│ ├── books/
|
||||||
@@ -56,10 +56,28 @@ spsays-training/
|
|||||||
│ │ ├── ochkm/ → On Chanting the Hare Kṛṣṇa Mantra (4 chapters)
|
│ │ ├── ochkm/ → On Chanting the Hare Kṛṣṇa Mantra (4 chapters)
|
||||||
│ │ ├── hkh/ → The Kṛṣṇa Consciousness Handbook (19 chapters)
|
│ │ ├── hkh/ → The Kṛṣṇa Consciousness Handbook (19 chapters)
|
||||||
│ │ ├── ssr/ → The Science of Self-Realization (8 chapters, 50 sub-chapters)
|
│ │ ├── ssr/ → The Science of Self-Realization (8 chapters, 50 sub-chapters)
|
||||||
│ │ └── ds/ → Dialectic Spiritualism (13 chapters, 36 sub-chapters)
|
│ │ ├── ds/ → Dialectic Spiritualism (13 chapters, 36 sub-chapters)
|
||||||
|
│ │ └── va/ → Vaiśiṣṭyāṣṭakam (8 chapters)
|
||||||
|
│ ├── magazines/
|
||||||
|
│ │ ├── page.tsx → Magazines index page (grouped by flag: "Pre 1978")
|
||||||
|
│ │ └── [slug]/
|
||||||
|
│ │ ├── page.tsx → Magazine issue listing (grouped by volume)
|
||||||
|
│ │ └── [issue]/page.tsx → Individual issue reader with sidebar
|
||||||
│ ├── essays/
|
│ ├── essays/
|
||||||
│ │ ├── page.tsx → Essays listing page
|
│ │ ├── page.tsx → Essays grid page (searchable)
|
||||||
|
│ │ ├── essays-grid.tsx → Client component for searchable essay grid
|
||||||
│ │ └── [slug]/page.tsx → Individual essay reader
|
│ │ └── [slug]/page.tsx → Individual essay reader
|
||||||
|
│ ├── journals/
|
||||||
|
│ │ ├── page.tsx → Journals index page (grid)
|
||||||
|
│ │ └── [slug]/
|
||||||
|
│ │ ├── page.tsx → Journal chapter listing (hierarchical: months → days)
|
||||||
|
│ │ └── [chapter]/page.tsx → Individual journal entry reader
|
||||||
|
│ ├── other-documents/
|
||||||
|
│ │ ├── page.tsx → Other documents grid page (searchable)
|
||||||
|
│ │ ├── other-documents-grid.tsx → Client component for searchable grid
|
||||||
|
│ │ └── [slug]/
|
||||||
|
│ │ ├── page.tsx → Document chapter listing
|
||||||
|
│ │ └── [chapter]/page.tsx → Individual chapter reader
|
||||||
│ ├── glossary/
|
│ ├── glossary/
|
||||||
│ │ ├── page.tsx → Server component wrapper
|
│ │ ├── page.tsx → Server component wrapper
|
||||||
│ │ └── glossary-client.tsx → Searchable client component (2749 entries)
|
│ │ └── glossary-client.tsx → Searchable client component (2749 entries)
|
||||||
@@ -104,6 +122,10 @@ spsays-training/
|
|||||||
│ ├── ssr.ts → Science of Self-Realization data access (Prisma, hierarchical)
|
│ ├── ssr.ts → Science of Self-Realization data access (Prisma, hierarchical)
|
||||||
│ ├── ds.ts → Dialectic Spiritualism data access (Prisma, hierarchical)
|
│ ├── ds.ts → Dialectic Spiritualism data access (Prisma, hierarchical)
|
||||||
│ ├── te.ts → Two Essays data access (Prisma, uses essays collection)
|
│ ├── te.ts → Two Essays data access (Prisma, uses essays collection)
|
||||||
|
│ ├── va.ts → Vaiśiṣṭyāṣṭakam data access (Prisma)
|
||||||
|
│ ├── other-documents.ts → Other Documents data access (Prisma, dedicated collections)
|
||||||
|
│ ├── journals.ts → Journals data access (Prisma, dedicated collections)
|
||||||
|
│ ├── magazines.ts → Magazines data access (Prisma, dedicated collections)
|
||||||
│ └── jsons/books/ → Extracted JSON data (all books)
|
│ └── jsons/books/ → Extracted JSON data (all books)
|
||||||
│ ├── bg/, sb/, cc/, sb-1962/, kb/ → Original verse/prose books
|
│ ├── bg/, sb/, cc/, sb-1962/, kb/ → Original verse/prose books
|
||||||
│ ├── nod/, tlc/, iso/, noi/ → Extracted from other.html
|
│ ├── nod/, tlc/, iso/, noi/ → Extracted from other.html
|
||||||
@@ -111,9 +133,16 @@ spsays-training/
|
|||||||
│ ├── rv/, poy/, pqpa/, ttp/, ochkm/ → More prose books
|
│ ├── rv/, poy/, pqpa/, ttp/, ochkm/ → More prose books
|
||||||
│ ├── hkh/, ssr/, ds/ → Large structured books
|
│ ├── hkh/, ssr/, ds/ → Large structured books
|
||||||
│ ├── te/ → Two Essays
|
│ ├── te/ → Two Essays
|
||||||
│ └── glossary/index.json → 2,749 glossary entries
|
│ ├── va/ → Vaiśiṣṭyāṣṭakam
|
||||||
|
│ ├── glossary/index.json → 2,749 glossary entries
|
||||||
|
│ ├── essays/ → Essay JSON files (pre-1977, cc-essays, standalone, etc.)
|
||||||
|
│ ├── other-documents/ → Other document JSON files (12 documents)
|
||||||
|
│ ├── journals/ → Journal JSON files (2 journals, 300 chapters)
|
||||||
|
│ └── magazines/ → Magazine JSON files (2 magazines, 59 issues)
|
||||||
|
│ ├── btg/ → Back to Godhead (1944–1960), 29 issues
|
||||||
|
│ └── btg-1978/ → Back to Godhead (1966–1973), 30 issues
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ └── schema.prisma → Verse, BookMeta, ProseChapter, Essay, GlossaryEntry models
|
│ └── schema.prisma → Verse, BookMeta, ProseChapter, Essay, GlossaryEntry, OtherDocumentMeta, OtherDocumentChapter, JournalMeta, JournalChapter, MagazineMeta, MagazineIssue models
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── bg_extract.py → BG extraction from bg.html
|
│ ├── bg_extract.py → BG extraction from bg.html
|
||||||
│ ├── sb_extract.py → SB extraction from sb.html
|
│ ├── sb_extract.py → SB extraction from sb.html
|
||||||
@@ -141,6 +170,15 @@ spsays-training/
|
|||||||
│ ├── seed-db.ts → Main MongoDB seeder (BG, SB, CC, SB1962, KB, glossary)
|
│ ├── seed-db.ts → Main MongoDB seeder (BG, SB, CC, SB1962, KB, glossary)
|
||||||
│ ├── seed-nod.ts … seed-ds.ts → Individual book seeders
|
│ ├── seed-nod.ts … seed-ds.ts → Individual book seeders
|
||||||
│ ├── seed-te.ts → Two Essays seeder (uses essays collection)
|
│ ├── seed-te.ts → Two Essays seeder (uses essays collection)
|
||||||
|
│ ├── seed-va.ts → Vaiśiṣṭyāṣṭakam seeder
|
||||||
|
│ ├── seed-pre-1977-essays.ts → Pre-1977 essays seeder (13 essays)
|
||||||
|
│ ├── seed-other-documents.ts → Other documents seeder (dedicated collections)
|
||||||
|
│ ├── seed-journals.ts → Journals seeder (dedicated collections)
|
||||||
|
│ ├── jaladuta_diary_extract.py → Jaladuta Diary extraction (83 day entries)
|
||||||
|
│ ├── the_beginning_extract.py → The Beginning extraction (189 day entries + 20 excerpts)
|
||||||
|
│ ├── btg_extract.py → BTG (1944–1960) extraction from btg-1960.html
|
||||||
|
│ ├── btg_1978_extract.py → BTG (1966–1973) extraction from btg-1978.html
|
||||||
|
│ ├── seed-magazines.ts → Magazines seeder (dedicated collections)
|
||||||
│ ├── kb_extract_*.py → KB extraction scripts (intro, vol1, vol2)
|
│ ├── kb_extract_*.py → KB extraction scripts (intro, vol1, vol2)
|
||||||
│ ├── inject-fonts.py → Inject @font-face rules into converted HTML
|
│ ├── inject-fonts.py → Inject @font-face rules into converted HTML
|
||||||
│ └── fix-bengali-encoding.py → Fix Bengali font encoding in cc.html
|
│ └── fix-bengali-encoding.py → Fix Bengali font encoding in cc.html
|
||||||
@@ -226,6 +264,77 @@ model Essay {
|
|||||||
@@map("essays")
|
@@map("essays")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model OtherDocumentMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("other_document_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OtherDocumentChapter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
chapter Int
|
||||||
|
title String
|
||||||
|
type String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@map("other_document_chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model JournalMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("journal_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model JournalChapter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
chapter Int
|
||||||
|
title String
|
||||||
|
type String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@map("journal_chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MagazineMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("magazine_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MagazineIssue {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
volume Int
|
||||||
|
part Int
|
||||||
|
title String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@index([book, volume])
|
||||||
|
@@map("magazine_issues")
|
||||||
|
}
|
||||||
|
|
||||||
model GlossaryEntry {
|
model GlossaryEntry {
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
term String
|
term String
|
||||||
@@ -285,6 +394,7 @@ model GlossaryEntry {
|
|||||||
| Śrī Caitanya-bhāgavata Commentary | `cbhc` | 4 | others-before-1977.html |
|
| Śrī Caitanya-bhāgavata Commentary | `cbhc` | 4 | others-before-1977.html |
|
||||||
| Bhagavāner Kathā | `bk` | 2 | others-before-1977.html (ScaGoudy) |
|
| Bhagavāner Kathā | `bk` | 2 | others-before-1977.html (ScaGoudy) |
|
||||||
| Prayers of King Kulaśekhara (1958) | `pk` | 6 | others-before-1977.html |
|
| Prayers of King Kulaśekhara (1958) | `pk` | 6 | others-before-1977.html |
|
||||||
|
| Vaiśiṣṭyāṣṭakam | `va` | 8 | others-before-1977.html |
|
||||||
|
|
||||||
### Essays (stored in `essays` collection)
|
### Essays (stored in `essays` collection)
|
||||||
|
|
||||||
@@ -297,18 +407,53 @@ model GlossaryEntry {
|
|||||||
| The Causes of Lord Caitanya's Incarnation | `causes-of-lord-caitanyas-incarnation` | `cc-essays` | 1957 |
|
| The Causes of Lord Caitanya's Incarnation | `causes-of-lord-caitanyas-incarnation` | `cc-essays` | 1957 |
|
||||||
| The Confidential Reasons for Lord Caitanya's Appearance | `confidential-reasons-for-lord-caitanyas-appearance` | `cc-essays` | 1957 |
|
| The Confidential Reasons for Lord Caitanya's Appearance | `confidential-reasons-for-lord-caitanyas-appearance` | `cc-essays` | 1957 |
|
||||||
| The Glories of Lord Nityānanda | `glories-of-lord-nityananda` | `cc-essays` | 1957 |
|
| The Glories of Lord Nityānanda | `glories-of-lord-nityananda` | `cc-essays` | 1957 |
|
||||||
|
| Man Is the Architect of His Own Fortune | `man-is-the-architect-of-his-own-fortune` | `pre-1977-essays` | — |
|
||||||
|
| What Is the Matter with the World? | `what-is-the-matter-with-the-world` | `pre-1977-essays` | 1957 |
|
||||||
|
| Religion Is Godly Life | `religion-is-godly-life` | `pre-1977-essays` | — |
|
||||||
|
| Truth and Beauty | `truth-and-beauty` | `pre-1977-essays` | 1958 |
|
||||||
|
| Misuse of Public Fund in the Name of Religion | `misuse-of-public-fund-in-the-name-of-religion` | `pre-1977-essays` | 1958 |
|
||||||
|
| Perfection at Home | `perfection-at-home` | `pre-1977-essays` | — |
|
||||||
|
| How Should One Cultivate Human Spirit | `how-should-one-cultivate-human-spirit` | `pre-1977-essays` | 1961 |
|
||||||
|
| Viswa Dharma | `viswa-dharma` | `pre-1977-essays` | — |
|
||||||
|
| Glorification of Śrīla Bhaktisiddhānta Sarasvatī Ṭhākura | `glorification-of-srila-bhaktisiddhanta-sarasvati-thakura` | `pre-1977-essays` | 1952 |
|
||||||
|
| Who Is Crazy? (Back to Godhead #66) | `who-is-crazy-btg66` | `pre-1977-essays` | — |
|
||||||
|
| Manifestation of Dharma in Human Being | `manifestation-of-dharma-in-human-being` | `pre-1977-essays` | 1972 |
|
||||||
|
| Message of Krishna Consciousness | `message-of-krishna-consciousness` | `pre-1977-essays` | 1972 |
|
||||||
|
| A Wrong Civilization | `a-wrong-civilization` | `pre-1977-essays` | 1936 |
|
||||||
| Vedānta-sūtra (Typed Transcript) | `vedanta-sutra-typed` | `typed-transcripts` | 1969 |
|
| Vedānta-sūtra (Typed Transcript) | `vedanta-sutra-typed` | `typed-transcripts` | 1969 |
|
||||||
| Shaktipujah | `shaktipujah` | `reviews` | — |
|
| Shaktipujah | `shaktipujah` | `reviews` | — |
|
||||||
| Devotee and the Divinity | `devotee-and-the-divinity` | `standalone` | — |
|
| Devotee and the Divinity | `devotee-and-the-divinity` | `standalone` | — |
|
||||||
|
|
||||||
### Other Documents (stored in `book_meta` + `prose_chapters` with `od-` prefix)
|
### Other Documents (stored in `other_document_meta` + `other_document_chapters` collections)
|
||||||
|
|
||||||
| Document | Code | Chapters |
|
| Document | Code | Chapters |
|
||||||
|----------|------|----------|
|
|----------|------|----------|
|
||||||
| An Analysis of Bhagwat Geeta | `od-analysis-bg` | 3 |
|
| An Analysis of Bhagwat Geeta | `analysis-bg` | 3 |
|
||||||
| Solution of Present Crisis by Bhagwat Geeta | `od-solution-crisis` | 4 |
|
| Solution of Present Crisis by Bhagwat Geeta | `solution-crisis` | 4 |
|
||||||
| Śrī Caitanya Mahāprabhu | `od-sri-caitanya` | 3 |
|
| Śrī Caitanya Mahāprabhu | `sri-caitanya` | 3 |
|
||||||
| Conception of Geeta Nagari on the Principles of Gandhi | `od-geeta-nagari` | 1 |
|
| Conception of Geeta Nagari on the Principles of Gandhi | `geeta-nagari` | 1 |
|
||||||
|
| The Real Peace Formula | `real-peace-formula` | 3 |
|
||||||
|
| International Institution for God Consciousness | `intl-institution-god-consciousness` | 1 |
|
||||||
|
| "Srimad Bhawatam" India's Message of Peace and Goodwill | `srimad-bhawatam-message` | 1 |
|
||||||
|
| Viraha Aṣṭaka | `viraha-astaka` | 8 |
|
||||||
|
| Vṛndāvana Bhajana | `vrndavana-bhajana` | 1 |
|
||||||
|
| Prayer unto the Lotus Feet of Kṛṣṇa | `prayer-unto-lotus-feet-of-krsna` | 1 |
|
||||||
|
| Märkine Bhägavata-dharma | `markine-bhagavata-dharma` | 1 |
|
||||||
|
| Jaladuta Pamphlet | `jaladuta-pamphlet` | 1 |
|
||||||
|
|
||||||
|
### Journals (stored in `journal_meta` + `journal_chapters` collections)
|
||||||
|
|
||||||
|
| Journal | Code | Chapters | Structure |
|
||||||
|
|---------|------|----------|-----------|
|
||||||
|
| The Jaladuta Diary, 1965 | `jaladuta-diary` | 85 | Preface, Introduction, 83 day entries grouped by month (Apr–Nov 1965) |
|
||||||
|
| The Beginning — The 1966 New York Journal | `the-beginning` | 215 | Introduction, 3 place-based chapters with day entries, Memoranda, Epilogue, 20 Excerpts |
|
||||||
|
|
||||||
|
### Magazines (stored in `magazine_meta` + `magazine_issues` collections)
|
||||||
|
|
||||||
|
| Magazine | Code | Issues | Articles | Flag | Source |
|
||||||
|
|----------|------|--------|----------|------|--------|
|
||||||
|
| Back to Godhead (1944–1960) | `btg` | 29 | 89 | Pre 1978 | btg-1960.html |
|
||||||
|
| Back to Godhead (1966–1973) | `btg-1978` | 30 | 1,035 | Pre 1978 | btg-1978.html |
|
||||||
|
|
||||||
### Other
|
### Other
|
||||||
|
|
||||||
@@ -389,17 +534,33 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
|
|||||||
|
|
||||||
| Route | Content |
|
| Route | Content |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `/essays` | All essays listing (10 essays across 4 groups) |
|
| `/essays` | Searchable essay grid (23 essays across 6 groups) |
|
||||||
| `/essays/[slug]` | Individual essay reader |
|
| `/essays/[slug]` | Individual essay reader |
|
||||||
|
|
||||||
### Other Documents
|
### Other Documents
|
||||||
|
|
||||||
| Route | Content |
|
| Route | Content |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `/other-documents` | All other documents listing |
|
| `/other-documents` | Searchable other documents grid (12 documents) |
|
||||||
| `/other-documents/[slug]` | Document index (chapter listing) |
|
| `/other-documents/[slug]` | Document index (chapter listing) |
|
||||||
| `/other-documents/[slug]/[chapter]` | Individual chapter reader |
|
| `/other-documents/[slug]/[chapter]` | Individual chapter reader |
|
||||||
|
|
||||||
|
### Journals
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/journals` | Journals index (grid) |
|
||||||
|
| `/journals/[slug]` | Journal chapter listing (hierarchical: place → month → day) |
|
||||||
|
| `/journals/[slug]/[chapter]` | Individual journal entry reader (slug-based, e.g., `apr-3`, `sep-17`, `excerpt-...`) |
|
||||||
|
|
||||||
|
### Magazines
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/magazines` | Magazines index (grouped by flag: "Pre 1978 Editions") |
|
||||||
|
| `/magazines/[slug]` | Magazine issue listing (grouped by volume) |
|
||||||
|
| `/magazines/[slug]/[issue]` | Individual issue reader (slug-based, e.g., `v1-p1`, `no-17`) |
|
||||||
|
|
||||||
### Other routes
|
### Other routes
|
||||||
|
|
||||||
| Route | Content |
|
| Route | Content |
|
||||||
@@ -407,6 +568,8 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
|
|||||||
| `/` | Landing page |
|
| `/` | Landing page |
|
||||||
| `/books` | Searchable book grid |
|
| `/books` | Searchable book grid |
|
||||||
| `/glossary` | Searchable glossary (2,749 entries) |
|
| `/glossary` | Searchable glossary (2,749 entries) |
|
||||||
|
| `/journals` | Journals (2 journals, 300 chapters) |
|
||||||
|
| `/magazines` | Magazines (2 magazines, 59 issues, Pre 1978) |
|
||||||
| `/rtfs` | RTF viewer (gated by ALLOW_RTF=true) |
|
| `/rtfs` | RTF viewer (gated by ALLOW_RTF=true) |
|
||||||
| `/api/verify-access` | Password protection endpoint |
|
| `/api/verify-access` | Password protection endpoint |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getLetter, getAdjacentLetters } from "@/lib/letters";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default async function LetterPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const letter = await getLetter(slug);
|
||||||
|
if (!letter) notFound();
|
||||||
|
|
||||||
|
const { prev, next } = await getAdjacentLetters(slug, letter.year);
|
||||||
|
|
||||||
|
const backHref = letter.year ? `/letters/year/${letter.year}` : "/letters";
|
||||||
|
const backLabel = letter.year ? `Letters from ${letter.year}` : "Letters";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PageHeader sticky>
|
||||||
|
<Link
|
||||||
|
href={backHref}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
← {backLabel}
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||||
|
Letter to {letter.recipientDisplay}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{letter.date}
|
||||||
|
{letter.location ? ` · ${letter.location}` : ""}
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||||
|
{/* Top nav */}
|
||||||
|
<LetterNav prev={prev} next={next} />
|
||||||
|
|
||||||
|
{/* Letter content */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 px-6 sm:px-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{letter.paragraphs.map((paragraph, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-base text-foreground leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bottom nav */}
|
||||||
|
<LetterNav prev={prev} next={next} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LetterNav({
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
}: {
|
||||||
|
prev: { slug: string; recipientDisplay: string } | null;
|
||||||
|
next: { slug: string; recipientDisplay: string } | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{prev ? (
|
||||||
|
<Link
|
||||||
|
href={`/letters/${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.recipientDisplay}
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden">Previous</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{next ? (
|
||||||
|
<Link
|
||||||
|
href={`/letters/${next.slug}`}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
"gap-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||||
|
{next.recipientDisplay}
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden">Next</span>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type YearGroup = { year: number; count: number };
|
||||||
|
export type RecipientGroup = { name: string; count: number };
|
||||||
|
export type LocationGroup = { name: string; count: number };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Diacritic-aware normalize (matches books-grid pattern)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function normalize(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ṛ/g, "r")
|
||||||
|
.replace(/ṝ/g, "r")
|
||||||
|
.replace(/ṣ/g, "s")
|
||||||
|
.replace(/ś/g, "s")
|
||||||
|
.replace(/ṭ/g, "t")
|
||||||
|
.replace(/ḍ/g, "d")
|
||||||
|
.replace(/ṇ/g, "n")
|
||||||
|
.replace(/ñ/g, "n")
|
||||||
|
.replace(/ṅ/g, "n")
|
||||||
|
.replace(/ḥ/g, "h")
|
||||||
|
.replace(/ṁ/g, "m")
|
||||||
|
.replace(/ṃ/g, "m")
|
||||||
|
.replace(/ḷ/g, "l")
|
||||||
|
.replace(/ā/g, "a")
|
||||||
|
.replace(/ī/g, "i")
|
||||||
|
.replace(/ū/g, "u")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pagination
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PAGE_SIZE_RECIPIENTS = 50;
|
||||||
|
const PAGE_SIZE_LOCATIONS = 60;
|
||||||
|
const PAGE_SIZE_YEARS = 100; // years are small cards, show all usually
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||||
|
page <= 1
|
||||||
|
? "text-muted-foreground/40 cursor-not-allowed"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||||
|
page >= totalPages
|
||||||
|
? "text-muted-foreground/40 cursor-not-allowed"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function LettersBrowser({
|
||||||
|
currentView,
|
||||||
|
years,
|
||||||
|
recipients,
|
||||||
|
locations,
|
||||||
|
}: {
|
||||||
|
currentView: string;
|
||||||
|
years: YearGroup[];
|
||||||
|
recipients: RecipientGroup[];
|
||||||
|
locations: LocationGroup[];
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Reset page when query or view changes
|
||||||
|
const handleQueryChange = (q: string) => {
|
||||||
|
setQuery(q);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Filtered data ---
|
||||||
|
|
||||||
|
const filteredYears = useMemo(() => {
|
||||||
|
if (!query.trim()) return years;
|
||||||
|
const q = normalize(query.trim());
|
||||||
|
return years.filter((yg) => String(yg.year).includes(q));
|
||||||
|
}, [years, query]);
|
||||||
|
|
||||||
|
const filteredRecipients = useMemo(() => {
|
||||||
|
if (!query.trim()) return recipients;
|
||||||
|
const q = normalize(query.trim());
|
||||||
|
return recipients.filter((rg) => normalize(rg.name).includes(q));
|
||||||
|
}, [recipients, query]);
|
||||||
|
|
||||||
|
const filteredLocations = useMemo(() => {
|
||||||
|
if (!query.trim()) return locations;
|
||||||
|
const q = normalize(query.trim());
|
||||||
|
return locations.filter((lg) => normalize(lg.name).includes(q));
|
||||||
|
}, [locations, query]);
|
||||||
|
|
||||||
|
// --- Paginated data ---
|
||||||
|
|
||||||
|
const activeData =
|
||||||
|
currentView === "year"
|
||||||
|
? filteredYears
|
||||||
|
: currentView === "recipient"
|
||||||
|
? filteredRecipients
|
||||||
|
: filteredLocations;
|
||||||
|
|
||||||
|
const pageSize =
|
||||||
|
currentView === "year"
|
||||||
|
? PAGE_SIZE_YEARS
|
||||||
|
: currentView === "recipient"
|
||||||
|
? PAGE_SIZE_RECIPIENTS
|
||||||
|
: PAGE_SIZE_LOCATIONS;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(activeData.length / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const startIdx = (safePage - 1) * pageSize;
|
||||||
|
const endIdx = startIdx + pageSize;
|
||||||
|
|
||||||
|
const placeholder =
|
||||||
|
currentView === "year"
|
||||||
|
? "Search years..."
|
||||||
|
: currentView === "recipient"
|
||||||
|
? "Search recipients..."
|
||||||
|
: "Search locations...";
|
||||||
|
|
||||||
|
const resultCount = activeData.length;
|
||||||
|
const totalCount =
|
||||||
|
currentView === "year"
|
||||||
|
? years.length
|
||||||
|
: currentView === "recipient"
|
||||||
|
? recipients.length
|
||||||
|
: locations.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result count */}
|
||||||
|
{query.trim() && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{resultCount === 0
|
||||||
|
? "No results found"
|
||||||
|
: `${resultCount} of ${totalCount} ${currentView === "year" ? "years" : currentView === "recipient" ? "recipients" : "locations"}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Year view */}
|
||||||
|
{currentView === "year" && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||||
|
{(filteredYears.slice(startIdx, endIdx) as YearGroup[]).map((yg) => (
|
||||||
|
<Link key={yg.year} href={`/letters/year/${yg.year}`}>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
|
||||||
|
<CardContent className="py-4 px-4 text-center">
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{yg.year}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{yg.count} {yg.count === 1 ? "letter" : "letters"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recipient view */}
|
||||||
|
{currentView === "recipient" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(filteredRecipients.slice(startIdx, endIdx) as RecipientGroup[]).map(
|
||||||
|
(rg) => (
|
||||||
|
<Link
|
||||||
|
key={rg.name}
|
||||||
|
href={`/letters/recipient/${encodeURIComponent(rg.name)}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
|
||||||
|
<CardContent className="py-3 px-5 flex items-center justify-between">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{rg.name}
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0 ml-3">
|
||||||
|
{rg.count}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location view */}
|
||||||
|
{currentView === "location" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{(filteredLocations.slice(startIdx, endIdx) as LocationGroup[]).map(
|
||||||
|
(lg) => (
|
||||||
|
<Link
|
||||||
|
key={lg.name}
|
||||||
|
href={`/letters/location/${encodeURIComponent(lg.name)}`}
|
||||||
|
>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
|
||||||
|
<CardContent className="py-4 px-4 text-center">
|
||||||
|
<p className="font-medium text-foreground">{lg.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{lg.count} {lg.count === 1 ? "letter" : "letters"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Calendar, User, MapPin } from "lucide-react";
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ key: "year", label: "By Year", icon: Calendar },
|
||||||
|
{ key: "recipient", label: "By Recipient", icon: User },
|
||||||
|
{ key: "location", label: "By Location", icon: MapPin },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LettersNav({ currentView }: { currentView: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 border-b border-border pb-3">
|
||||||
|
{views.map((v) => {
|
||||||
|
const Icon = v.icon;
|
||||||
|
const isActive = currentView === v.key;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={v.key}
|
||||||
|
href={`/letters?view=${v.key}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{v.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getLettersByLocation } from "@/lib/letters";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function LettersByLocationPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ name: string }>;
|
||||||
|
}) {
|
||||||
|
const { name: encodedName } = await params;
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
|
|
||||||
|
const letters = await getLettersByLocation(name);
|
||||||
|
if (letters.length === 0) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PageHeader>
|
||||||
|
<Link
|
||||||
|
href="/letters?view=location"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
← Letters by Location
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||||
|
Letters from {name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{letters.length} {letters.length === 1 ? "letter" : "letters"}
|
||||||
|
</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">
|
||||||
|
{letters.map((letter) => (
|
||||||
|
<li key={letter.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/letters/${letter.slug}`}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
To: {letter.recipientDisplay}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{letter.date || "Undated"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import {
|
||||||
|
getLetterYears,
|
||||||
|
getLetterRecipients,
|
||||||
|
getLetterLocations,
|
||||||
|
getTotalLetterCount,
|
||||||
|
} from "@/lib/letters";
|
||||||
|
import { LettersNav } from "./letters-nav";
|
||||||
|
import { LettersBrowser } from "./letters-browser";
|
||||||
|
|
||||||
|
export default async function LettersPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ view?: string }>;
|
||||||
|
}) {
|
||||||
|
const { view = "year" } = await searchParams;
|
||||||
|
|
||||||
|
const [years, recipients, locations, totalCount] = await Promise.all([
|
||||||
|
getLetterYears(),
|
||||||
|
getLetterRecipients(),
|
||||||
|
getLetterLocations(),
|
||||||
|
getTotalLetterCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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">Letters</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{totalCount.toLocaleString()} letters by Śrīla Prabhupāda (1947–1977)
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
|
||||||
|
<LettersNav currentView={view} />
|
||||||
|
|
||||||
|
<LettersBrowser
|
||||||
|
currentView={view}
|
||||||
|
years={years}
|
||||||
|
recipients={recipients}
|
||||||
|
locations={locations}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getLettersByRecipient } from "@/lib/letters";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function LettersByRecipientPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ name: string }>;
|
||||||
|
}) {
|
||||||
|
const { name: encodedName } = await params;
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
|
|
||||||
|
const letters = await getLettersByRecipient(name);
|
||||||
|
if (letters.length === 0) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PageHeader>
|
||||||
|
<Link
|
||||||
|
href="/letters?view=recipient"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
← Letters by Recipient
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||||
|
Letters to or including {name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{letters.length} {letters.length === 1 ? "letter" : "letters"}
|
||||||
|
</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">
|
||||||
|
{letters.map((letter) => (
|
||||||
|
<li key={letter.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/letters/${letter.slug}`}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
To: {letter.recipientDisplay}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{letter.date || "Undated"}
|
||||||
|
{letter.location ? ` · ${letter.location}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getLettersByYear } from "@/lib/letters";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function LettersByYearPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ year: string }>;
|
||||||
|
}) {
|
||||||
|
const { year: yearStr } = await params;
|
||||||
|
const year = parseInt(yearStr, 10);
|
||||||
|
if (isNaN(year)) notFound();
|
||||||
|
|
||||||
|
const letters = await getLettersByYear(year);
|
||||||
|
if (letters.length === 0) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PageHeader>
|
||||||
|
<Link
|
||||||
|
href="/letters?view=year"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
← Letters by Year
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||||
|
Letters from {year}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{letters.length} {letters.length === 1 ? "letter" : "letters"}
|
||||||
|
</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">
|
||||||
|
{letters.map((letter) => (
|
||||||
|
<li key={letter.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/letters/${letter.slug}`}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
To: {letter.recipientDisplay}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{letter.date}
|
||||||
|
{letter.location ? ` · ${letter.location}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import {
|
||||||
|
getMagazineContent,
|
||||||
|
getMagazineMeta,
|
||||||
|
getMagazineSidebarItems,
|
||||||
|
slugToBookCode,
|
||||||
|
type MagazineSidebarItem,
|
||||||
|
} from "@/lib/magazines";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||||
|
|
||||||
|
export default async function MagazineIssuePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string; issue: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug, issue: issueSlug } = await params;
|
||||||
|
const bookCode = slugToBookCode(slug);
|
||||||
|
|
||||||
|
const [meta, content, sidebarItems] = await Promise.all([
|
||||||
|
getMagazineMeta(bookCode),
|
||||||
|
getMagazineContent(bookCode, issueSlug),
|
||||||
|
getMagazineSidebarItems(bookCode),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!meta || !content) notFound();
|
||||||
|
|
||||||
|
const navItems = sidebarItems.filter((item) => item.type !== "heading");
|
||||||
|
const currentIdx = navItems.findIndex((item) => item.slug === issueSlug);
|
||||||
|
const prev = currentIdx > 0 ? navItems[currentIdx - 1] : null;
|
||||||
|
const next =
|
||||||
|
currentIdx < navItems.length - 1 ? navItems[currentIdx + 1] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PageHeader sticky wide>
|
||||||
|
<Link
|
||||||
|
href={`/magazines/${slug}`}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
← {meta.title}
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||||
|
{content.title}
|
||||||
|
</h1>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="w-full md:max-w-6xl mx-auto flex">
|
||||||
|
<IssueSidebar
|
||||||
|
items={sidebarItems}
|
||||||
|
currentSlug={issueSlug}
|
||||||
|
magSlug={slug}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
|
<IssueNav
|
||||||
|
prev={prev}
|
||||||
|
next={next}
|
||||||
|
navItems={navItems}
|
||||||
|
currentSlug={issueSlug}
|
||||||
|
magSlug={slug}
|
||||||
|
magTitle={meta.title}
|
||||||
|
showMobileNav
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 px-6 sm:px-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{content.paragraphs.map((paragraph, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-base text-foreground leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<IssueNav
|
||||||
|
prev={prev}
|
||||||
|
next={next}
|
||||||
|
navItems={navItems}
|
||||||
|
currentSlug={issueSlug}
|
||||||
|
magSlug={slug}
|
||||||
|
magTitle={meta.title}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueSidebar({
|
||||||
|
items,
|
||||||
|
currentSlug,
|
||||||
|
magSlug,
|
||||||
|
}: {
|
||||||
|
items: MagazineSidebarItem[];
|
||||||
|
currentSlug: string;
|
||||||
|
magSlug: 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">
|
||||||
|
Issues
|
||||||
|
</h2>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item.type === "heading" ? (
|
||||||
|
<h3
|
||||||
|
key={`item-${i}`}
|
||||||
|
className="text-xs font-bold uppercase tracking-wider text-muted-foreground mt-4 mb-1 px-2"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</h3>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={`item-${i}`}
|
||||||
|
href={`/magazines/${magSlug}/${item.slug}`}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||||
|
item.slug === currentSlug
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueNav({
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
navItems,
|
||||||
|
currentSlug,
|
||||||
|
magSlug,
|
||||||
|
magTitle,
|
||||||
|
showMobileNav = false,
|
||||||
|
}: {
|
||||||
|
prev: MagazineSidebarItem | null;
|
||||||
|
next: MagazineSidebarItem | null;
|
||||||
|
navItems: MagazineSidebarItem[];
|
||||||
|
currentSlug: string;
|
||||||
|
magSlug: string;
|
||||||
|
magTitle: string;
|
||||||
|
showMobileNav?: boolean;
|
||||||
|
}) {
|
||||||
|
const mobileNavGroups: MobileNavGroup[] = [];
|
||||||
|
if (showMobileNav && navItems.length > 0) {
|
||||||
|
mobileNavGroups.push({
|
||||||
|
title: "Issues",
|
||||||
|
items: navItems.map((i) => ({
|
||||||
|
slug: i.slug,
|
||||||
|
label: i.label,
|
||||||
|
href: `/magazines/${magSlug}/${i.slug}`,
|
||||||
|
type: i.type,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showMobileNav && (
|
||||||
|
<MobileNav
|
||||||
|
groups={mobileNavGroups}
|
||||||
|
currentSlug={currentSlug}
|
||||||
|
bookTitle={magTitle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{prev ? (
|
||||||
|
<Link
|
||||||
|
href={`/magazines/${magSlug}/${prev.slug}`}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
"gap-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||||
|
{prev.label}
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden">Previous</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="hidden lg:block" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{next ? (
|
||||||
|
<Link
|
||||||
|
href={`/magazines/${magSlug}/${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,96 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import {
|
||||||
|
getMagazineMeta,
|
||||||
|
slugToBookCode,
|
||||||
|
type MagazineChapterEntry,
|
||||||
|
} from "@/lib/magazines";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function MagazineIndexPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const bookCode = slugToBookCode(slug);
|
||||||
|
const meta = await getMagazineMeta(bookCode);
|
||||||
|
if (!meta) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PageHeader>
|
||||||
|
<Link
|
||||||
|
href="/magazines"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
← Magazines
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||||
|
{meta.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||||
|
{meta.description && (
|
||||||
|
<p className="text-muted-foreground">{meta.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.chapters.map((ch: MagazineChapterEntry, idx: number) => {
|
||||||
|
if ("slug" in ch && !("subChapters" in ch)) {
|
||||||
|
return (
|
||||||
|
<Card key={ch.slug}>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Link
|
||||||
|
href={`/magazines/${slug}/${ch.slug}`}
|
||||||
|
className="flex items-center px-5 py-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-foreground">{ch.title}</p>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("subChapters" in ch) {
|
||||||
|
const group = ch as {
|
||||||
|
title: string;
|
||||||
|
subChapters: { slug: string; title: string }[];
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={`group-${idx}`}>
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||||
|
{group.title}
|
||||||
|
</h2>
|
||||||
|
<Card className="divide-y divide-border">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{group.subChapters.map((sc) => (
|
||||||
|
<li key={sc.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/magazines/${slug}/${sc.slug}`}
|
||||||
|
className="flex items-center px-5 py-3 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{sc.title}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { getAllMagazines, bookCodeToSlug } from "@/lib/magazines";
|
||||||
|
|
||||||
|
export default async function MagazinesPage() {
|
||||||
|
const magazines = await getAllMagazines();
|
||||||
|
|
||||||
|
// Group magazines by flag
|
||||||
|
const grouped: Record<string, typeof magazines> = {};
|
||||||
|
for (const mag of magazines) {
|
||||||
|
const key = mag.flag ?? "Other";
|
||||||
|
if (!grouped[key]) grouped[key] = [];
|
||||||
|
grouped[key].push(mag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define display order for groups
|
||||||
|
const groupOrder = ["Pre 1978", "Other"];
|
||||||
|
const groupLabels: Record<string, string> = {
|
||||||
|
"Pre 1978": "Pre 1978 Editions",
|
||||||
|
Other: "Other Editions",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedGroups = Object.keys(grouped).sort(
|
||||||
|
(a, b) => groupOrder.indexOf(a) - groupOrder.indexOf(b),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there's only one group, don't show group headings
|
||||||
|
const showGroupHeadings = sortedGroups.length > 1;
|
||||||
|
|
||||||
|
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">Magazines</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Back to Godhead and other periodicals by Śrīla Prabhupāda
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-6 py-8 space-y-10">
|
||||||
|
{sortedGroups.map((group) => (
|
||||||
|
<div key={group}>
|
||||||
|
{showGroupHeadings && (
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
|
{groupLabels[group] ?? group}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{grouped[group].map((mag) => (
|
||||||
|
<Link
|
||||||
|
key={mag.book}
|
||||||
|
href={`/magazines/${bookCodeToSlug(mag.book)}`}
|
||||||
|
className="block h-full"
|
||||||
|
>
|
||||||
|
<Card className="h-full transition-all hover:shadow-md hover:ring-foreground/20">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base font-bold leading-snug">
|
||||||
|
{mag.title}
|
||||||
|
</CardTitle>
|
||||||
|
{mag.description && (
|
||||||
|
<CardDescription className="text-xs line-clamp-3">
|
||||||
|
{mag.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{mag.flag && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{mag.flag}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{mag.year && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Since {mag.year}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{mag.totalIssues > 0 && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{mag.totalIssues} issues
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+71
-14
@@ -16,6 +16,8 @@ import {
|
|||||||
PenLine,
|
PenLine,
|
||||||
FileStack,
|
FileStack,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
|
Newspaper,
|
||||||
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const allowRtf = process.env.ALLOW_RTF === "true";
|
const allowRtf = process.env.ALLOW_RTF === "true";
|
||||||
@@ -27,11 +29,11 @@ export default function Home() {
|
|||||||
<Logo height={56} className="mb-1" />
|
<Logo height={56} className="mb-1" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div className="grid gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Structured Books (JSON) */}
|
{/* Structured Books (JSON) */}
|
||||||
<Link href="/books" className="block group">
|
<Link href="/books" className="block group h-full">
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -58,8 +60,8 @@ export default function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Essays */}
|
{/* Essays */}
|
||||||
<Link href="/essays" className="block group">
|
<Link href="/essays" className="block group h-full">
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -83,9 +85,64 @@ export default function Home() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Magazines */}
|
||||||
|
<Link href="/magazines" className="block group h-full">
|
||||||
|
<Card className="h-full 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">
|
||||||
|
<Newspaper className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-xl font-bold">
|
||||||
|
Magazines
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Back to Godhead & other periodicals
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The original Back to Godhead magazine founded by Śrīla
|
||||||
|
Prabhupāda in 1944 — 29 issues across 3 volumes with 89
|
||||||
|
articles on Kṛṣṇa consciousness, Vedic philosophy, and
|
||||||
|
spiritual life.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Letters */}
|
||||||
|
<Link href="/letters" className="block group h-full">
|
||||||
|
<Card className="h-full 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">
|
||||||
|
<Mail className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-xl font-bold">Letters</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
6,800+ letters (1947–1977)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Śrīla Prabhupāda's personal correspondence — over 6,800
|
||||||
|
letters to disciples, Godbrothers, government officials, and
|
||||||
|
well-wishers. Browse by year, recipient, or location.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Journals */}
|
{/* Journals */}
|
||||||
<Link href="/journals" className="block group">
|
<Link href="/journals" className="block group h-full">
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -113,8 +170,8 @@ export default function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Glossary */}
|
{/* Glossary */}
|
||||||
<Link href="/glossary" className="block group">
|
<Link href="/glossary" className="block group h-full">
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -141,8 +198,8 @@ export default function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Other Documents */}
|
{/* Other Documents */}
|
||||||
<Link href="/other-documents" className="block group">
|
<Link href="/other-documents" className="block group h-full">
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -171,8 +228,8 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* RTF Document Viewer — only visible when ALLOW_RTF=true */}
|
{/* RTF Document Viewer — only visible when ALLOW_RTF=true */}
|
||||||
{allowRtf && (
|
{allowRtf && (
|
||||||
<Link href="/rtfs" className="block group">
|
<Link href="/rtfs" className="block group h-full">
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
||||||
|
|||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Data access layer for Letters.
|
||||||
|
*
|
||||||
|
* Provides queries for browsing letters by year, recipient, and location.
|
||||||
|
* Uses the Prisma Letter model (collection: letters).
|
||||||
|
*
|
||||||
|
* Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]).
|
||||||
|
* The recipient list aggregates individual names across all letters,
|
||||||
|
* and searching for "Mukunda" finds all letters where Mukunda is
|
||||||
|
* ANY recipient (individual or group).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type LetterSummary = {
|
||||||
|
slug: string;
|
||||||
|
recipients: string[];
|
||||||
|
recipientDisplay: string;
|
||||||
|
date: string | null;
|
||||||
|
year: number | null;
|
||||||
|
month: number | null;
|
||||||
|
location: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LetterFull = {
|
||||||
|
slug: string;
|
||||||
|
recipients: string[];
|
||||||
|
recipientDisplay: string;
|
||||||
|
date: string | null;
|
||||||
|
dateRef: string | null;
|
||||||
|
year: number | null;
|
||||||
|
month: number | null;
|
||||||
|
day: number | null;
|
||||||
|
location: string | null;
|
||||||
|
salutation: string | null;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type YearGroup = {
|
||||||
|
year: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipientGroup = {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationGroup = {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitize
|
||||||
|
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, "");
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Index/overview queries ---
|
||||||
|
|
||||||
|
export async function getLetterYears(): Promise<YearGroup[]> {
|
||||||
|
const letters = await prisma.letter.findMany({
|
||||||
|
where: { year: { not: null } },
|
||||||
|
select: { year: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts: Record<number, number> = {};
|
||||||
|
for (const l of letters) {
|
||||||
|
if (l.year) {
|
||||||
|
counts[l.year] = (counts[l.year] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([year, count]) => ({ year: Number(year), count }))
|
||||||
|
.filter((g) => g.year >= 1940 && g.year <= 1980)
|
||||||
|
.sort((a, b) => a.year - b.year);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unique individual recipients with letter counts.
|
||||||
|
*
|
||||||
|
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
|
||||||
|
* This function aggregates across all letters so that "Mukunda" appears
|
||||||
|
* once with count = total letters where Mukunda is ANY recipient.
|
||||||
|
*/
|
||||||
|
export async function getLetterRecipients(): Promise<RecipientGroup[]> {
|
||||||
|
const letters = await prisma.letter.findMany({
|
||||||
|
select: { recipients: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const l of letters) {
|
||||||
|
for (const r of l.recipients) {
|
||||||
|
counts[r] = (counts[r] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLetterLocations(): Promise<LocationGroup[]> {
|
||||||
|
const letters = await prisma.letter.findMany({
|
||||||
|
where: { location: { not: null } },
|
||||||
|
select: { location: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const l of letters) {
|
||||||
|
if (l.location) {
|
||||||
|
counts[l.location] = (counts[l.location] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Letter list queries ---
|
||||||
|
|
||||||
|
export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
|
||||||
|
const letters = await prisma.letter.findMany({
|
||||||
|
where: { year },
|
||||||
|
orderBy: [{ month: "asc" }, { day: "asc" }],
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
recipients: true,
|
||||||
|
recipientDisplay: true,
|
||||||
|
date: true,
|
||||||
|
year: true,
|
||||||
|
month: true,
|
||||||
|
location: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return letters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all letters where the given name is ANY recipient.
|
||||||
|
*
|
||||||
|
* Uses Prisma's `has` filter on the recipients array.
|
||||||
|
* Searching for "Mukunda" returns letters addressed to:
|
||||||
|
* - "Mukunda" (individual)
|
||||||
|
* - "Mukunda, Janaki" (group containing Mukunda)
|
||||||
|
* - "Yamuna, Mukunda, Syamasundara" (group containing Mukunda)
|
||||||
|
*/
|
||||||
|
export async function getLettersByRecipient(
|
||||||
|
recipient: string,
|
||||||
|
): Promise<LetterSummary[]> {
|
||||||
|
const letters = await prisma.letter.findMany({
|
||||||
|
where: { recipients: { has: recipient } },
|
||||||
|
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
recipients: true,
|
||||||
|
recipientDisplay: true,
|
||||||
|
date: true,
|
||||||
|
year: true,
|
||||||
|
month: true,
|
||||||
|
location: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return letters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLettersByLocation(
|
||||||
|
location: string,
|
||||||
|
): Promise<LetterSummary[]> {
|
||||||
|
const letters = await prisma.letter.findMany({
|
||||||
|
where: { location },
|
||||||
|
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
recipients: true,
|
||||||
|
recipientDisplay: true,
|
||||||
|
date: true,
|
||||||
|
year: true,
|
||||||
|
month: true,
|
||||||
|
location: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return letters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Single letter ---
|
||||||
|
|
||||||
|
export async function getLetter(slug: string): Promise<LetterFull | null> {
|
||||||
|
const letter = await prisma.letter.findUnique({ where: { slug } });
|
||||||
|
if (!letter) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: letter.slug,
|
||||||
|
recipients: letter.recipients,
|
||||||
|
recipientDisplay: letter.recipientDisplay,
|
||||||
|
date: letter.date,
|
||||||
|
dateRef: letter.dateRef,
|
||||||
|
year: letter.year,
|
||||||
|
month: letter.month,
|
||||||
|
day: letter.day,
|
||||||
|
location: letter.location,
|
||||||
|
salutation: letter.salutation,
|
||||||
|
paragraphs: letter.paragraphs.map(sanitizeHtml),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation helpers ---
|
||||||
|
|
||||||
|
export async function getAdjacentLetters(
|
||||||
|
slug: string,
|
||||||
|
year: number | null,
|
||||||
|
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
|
||||||
|
if (!year) return { prev: null, next: null };
|
||||||
|
|
||||||
|
const yearLetters = await prisma.letter.findMany({
|
||||||
|
where: { year },
|
||||||
|
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
recipients: true,
|
||||||
|
recipientDisplay: true,
|
||||||
|
date: true,
|
||||||
|
year: true,
|
||||||
|
month: true,
|
||||||
|
location: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const idx = yearLetters.findIndex((l) => l.slug === slug);
|
||||||
|
return {
|
||||||
|
prev: idx > 0 ? yearLetters[idx - 1] : null,
|
||||||
|
next: idx < yearLetters.length - 1 ? yearLetters[idx + 1] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTotalLetterCount(): Promise<number> {
|
||||||
|
return prisma.letter.count();
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Data access layer for Magazines (Back to Godhead, etc.).
|
||||||
|
*
|
||||||
|
* Uses dedicated Prisma models:
|
||||||
|
* - MagazineMeta (collection: magazine_meta)
|
||||||
|
* - MagazineIssue (collection: magazine_issues)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const LOCAL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "magazines");
|
||||||
|
|
||||||
|
export type MagazineSubChapter = { slug: string; title: string };
|
||||||
|
export type MagazineVolumeGroup = {
|
||||||
|
title: string;
|
||||||
|
subChapters: MagazineSubChapter[];
|
||||||
|
};
|
||||||
|
export type MagazineChapterEntry = MagazineSubChapter | MagazineVolumeGroup;
|
||||||
|
|
||||||
|
export type MagazineMetaData = {
|
||||||
|
book: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
year: number | null;
|
||||||
|
description: string | null;
|
||||||
|
totalIssues: number;
|
||||||
|
totalArticles: number;
|
||||||
|
flag: string | null;
|
||||||
|
chapters: MagazineChapterEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MagazineIssueData = {
|
||||||
|
book: string;
|
||||||
|
volume: number;
|
||||||
|
part: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MagazineSummary = {
|
||||||
|
book: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
year: number | null;
|
||||||
|
description: string | null;
|
||||||
|
totalIssues: number;
|
||||||
|
flag: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MagazineSidebarItem = {
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLocalDirForBook(bookCode: string): string | null {
|
||||||
|
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
||||||
|
const entries = fs.readdirSync(LOCAL_JSON_DIR);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json");
|
||||||
|
if (fs.existsSync(introPath)) {
|
||||||
|
try {
|
||||||
|
const intro = JSON.parse(fs.readFileSync(introPath, "utf-8"));
|
||||||
|
if (intro.book === bookCode) return entry;
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocalJson<T>(filePath: string): T | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllLocalMetas(): MagazineMetaData[] {
|
||||||
|
if (!fs.existsSync(LOCAL_JSON_DIR)) return [];
|
||||||
|
const entries = fs.readdirSync(LOCAL_JSON_DIR).sort();
|
||||||
|
const metas: MagazineMetaData[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const intro = readLocalJson<MagazineMetaData>(
|
||||||
|
path.join(LOCAL_JSON_DIR, entry, "intro.json"),
|
||||||
|
);
|
||||||
|
if (intro) metas.push(intro);
|
||||||
|
}
|
||||||
|
return metas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalMeta(bookCode: string): MagazineMetaData | null {
|
||||||
|
const dir = findLocalDirForBook(bookCode);
|
||||||
|
if (!dir) return null;
|
||||||
|
return readLocalJson<MagazineMetaData>(
|
||||||
|
path.join(LOCAL_JSON_DIR, dir, "intro.json"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalIssue(
|
||||||
|
bookCode: string,
|
||||||
|
slug: string,
|
||||||
|
): MagazineIssueData | null {
|
||||||
|
const dir = findLocalDirForBook(bookCode);
|
||||||
|
if (!dir) return null;
|
||||||
|
const data = readLocalJson<MagazineIssueData>(
|
||||||
|
path.join(LOCAL_JSON_DIR, dir, "issues", `${slug}.json`),
|
||||||
|
);
|
||||||
|
if (!data) return null;
|
||||||
|
return { ...data, paragraphs: data.paragraphs.map(sanitizeHtml) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
|
||||||
|
export async function getAllMagazines(): Promise<MagazineSummary[]> {
|
||||||
|
try {
|
||||||
|
const results = await prisma.magazineMeta.findMany({
|
||||||
|
orderBy: { book: "asc" },
|
||||||
|
});
|
||||||
|
if (results.length > 0) {
|
||||||
|
return results.map((row) => {
|
||||||
|
const data = row.data as unknown as MagazineMetaData;
|
||||||
|
return {
|
||||||
|
book: row.book,
|
||||||
|
title: data.title,
|
||||||
|
author: data.author,
|
||||||
|
year: data.year ?? null,
|
||||||
|
description: data.description ?? null,
|
||||||
|
totalIssues: data.totalIssues,
|
||||||
|
flag: data.flag ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
const localMetas = getAllLocalMetas();
|
||||||
|
return localMetas.map((meta) => ({
|
||||||
|
book: meta.book,
|
||||||
|
title: meta.title,
|
||||||
|
author: meta.author,
|
||||||
|
year: meta.year ?? null,
|
||||||
|
description: meta.description ?? null,
|
||||||
|
totalIssues: meta.totalIssues,
|
||||||
|
flag: meta.flag ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMagazineMeta(
|
||||||
|
bookCode: string,
|
||||||
|
): Promise<MagazineMetaData | null> {
|
||||||
|
try {
|
||||||
|
const row = await prisma.magazineMeta.findUnique({
|
||||||
|
where: { book: bookCode },
|
||||||
|
});
|
||||||
|
if (row) return row.data as unknown as MagazineMetaData;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
return getLocalMeta(bookCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMagazineIssue(
|
||||||
|
bookCode: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<MagazineIssueData | null> {
|
||||||
|
if (!slug) return null;
|
||||||
|
try {
|
||||||
|
const doc = await prisma.magazineIssue.findUnique({
|
||||||
|
where: { book_slug: { book: bookCode, slug } },
|
||||||
|
});
|
||||||
|
if (doc) {
|
||||||
|
// Support both paragraphsJson (string) and paragraphs (array) formats.
|
||||||
|
// The seed script stores paragraphs as a JSON string to avoid
|
||||||
|
// MongoDB BSON wire protocol issues with large string arrays.
|
||||||
|
let paras: string[];
|
||||||
|
if (doc.paragraphsJson) {
|
||||||
|
try {
|
||||||
|
paras = JSON.parse(doc.paragraphsJson);
|
||||||
|
} catch {
|
||||||
|
paras = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paras = normalizeParagraphs(doc.paragraphs);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
book: doc.book,
|
||||||
|
volume: doc.volume,
|
||||||
|
part: doc.part,
|
||||||
|
title: doc.title,
|
||||||
|
slug: doc.slug,
|
||||||
|
paragraphs: paras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
return getLocalIssue(bookCode, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMagazineContent(
|
||||||
|
bookCode: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<MagazineIssueData | null> {
|
||||||
|
return getMagazineIssue(bookCode, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubChapter(ch: MagazineChapterEntry): ch is MagazineSubChapter {
|
||||||
|
return "slug" in ch && !("subChapters" in ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSubChapters(ch: MagazineChapterEntry): ch is MagazineVolumeGroup {
|
||||||
|
return "subChapters" in ch && Array.isArray(ch.subChapters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMagazineSidebarItems(
|
||||||
|
bookCode: string,
|
||||||
|
): Promise<MagazineSidebarItem[]> {
|
||||||
|
const meta = await getMagazineMeta(bookCode);
|
||||||
|
if (!meta) return [];
|
||||||
|
|
||||||
|
const items: MagazineSidebarItem[] = [];
|
||||||
|
function addEntry(entry: MagazineChapterEntry) {
|
||||||
|
if (isSubChapter(entry)) {
|
||||||
|
items.push({ slug: entry.slug, label: entry.title, type: "chapter" });
|
||||||
|
} else if (hasSubChapters(entry)) {
|
||||||
|
items.push({ slug: "", label: entry.title, type: "heading" });
|
||||||
|
for (const sc of entry.subChapters) {
|
||||||
|
addEntry(sc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ch of meta.chapters) {
|
||||||
|
addEntry(ch);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookCodeToSlug(bookCode: string): string {
|
||||||
|
return bookCode;
|
||||||
|
}
|
||||||
|
export function slugToBookCode(slug: string): string {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
@@ -198,9 +198,87 @@ model JournalChapter {
|
|||||||
@@map("journal_chapters")
|
@@map("journal_chapters")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MagazineMeta — metadata for magazines (Back to Godhead, etc.)
|
||||||
|
//
|
||||||
|
// book: slug identifier (e.g. "btg")
|
||||||
|
// data: JSON blob containing title, author, description, volumes/issues list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
model MagazineMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("magazine_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MagazineIssue — individual issues within a magazine
|
||||||
|
//
|
||||||
|
// book: slug identifier matching MagazineMeta.book
|
||||||
|
// volume: volume number
|
||||||
|
// part: part/issue number within the volume
|
||||||
|
// title: display title (e.g. "Volume 3, Part 5, May 5, 1956")
|
||||||
|
// slug: URL-friendly identifier (e.g. "v3-p5")
|
||||||
|
// order: sort order
|
||||||
|
// paragraphs: array of HTML paragraph strings (all articles concatenated)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
model MagazineIssue {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
volume Int
|
||||||
|
part Int
|
||||||
|
title String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
paragraphsJson String?
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@index([book, volume])
|
||||||
|
@@map("magazine_issues")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GlossaryEntry — individual glossary term + definition
|
// GlossaryEntry — individual glossary term + definition
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Letter — individual letters written by Śrīla Prabhupāda
|
||||||
|
//
|
||||||
|
// slug: unique identifier (e.g. "47-07-12-mahatma-gandhi", "68-01-15-hayagriva")
|
||||||
|
// recipients: array of individual recipient names (split from comma-separated)
|
||||||
|
// recipientDisplay: original recipient string for display (e.g. "Mukunda, Janaki")
|
||||||
|
// date: date string as written (e.g. "12 July, 1947")
|
||||||
|
// dateRef: reference code (e.g. "47-07-12")
|
||||||
|
// year: extracted year number for filtering/grouping
|
||||||
|
// month: extracted month number (1-12) for filtering
|
||||||
|
// day: extracted day number for filtering
|
||||||
|
// location: city/place where the letter was written
|
||||||
|
// salutation: the "Dear..." line
|
||||||
|
// paragraphs: array of HTML paragraph strings (letter body)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
model Letter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
slug String @unique
|
||||||
|
recipients String[]
|
||||||
|
recipientDisplay String
|
||||||
|
date String?
|
||||||
|
dateRef String?
|
||||||
|
year Int?
|
||||||
|
month Int?
|
||||||
|
day Int?
|
||||||
|
location String?
|
||||||
|
salutation String?
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@index([year])
|
||||||
|
@@index([recipients])
|
||||||
|
@@index([location])
|
||||||
|
@@index([year, month])
|
||||||
|
@@map("letters")
|
||||||
|
}
|
||||||
|
|
||||||
model GlossaryEntry {
|
model GlossaryEntry {
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
term String
|
term String
|
||||||
|
|||||||
@@ -0,0 +1,717 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract Back to Godhead magazine (1966-1973) from btg-1978.html.
|
||||||
|
|
||||||
|
Each magazine issue becomes its own JSON file.
|
||||||
|
Issues are grouped by volume/era in the intro.json metadata.
|
||||||
|
Advertisement sections are excluded.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- p2 = Issue bookmark (bold, e.g., "Back to Godhead Magazine. Vol. 1, No. 01, October 23, 1966")
|
||||||
|
- p8 = Article bookmark within an issue
|
||||||
|
- p9 = Article display title (bold)
|
||||||
|
- p7, p5, p12, p4, p3, etc. = Body text
|
||||||
|
- Articles titled "Advertisement" are skipped entirely
|
||||||
|
|
||||||
|
Output:
|
||||||
|
lib/jsons/magazines/btg-1978/intro.json
|
||||||
|
lib/jsons/magazines/btg-1978/issues/no-01.json
|
||||||
|
lib/jsons/magazines/btg-1978/issues/no-02.json
|
||||||
|
...
|
||||||
|
lib/jsons/magazines/btg-1978/issues/no-54.json
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/btg_1978_extract.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ScaGoudy → IAST Unicode mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCAGOUDY_TO_IAST = {
|
||||||
|
"ä": "ā",
|
||||||
|
"Ä": "Ā",
|
||||||
|
"å": "ṛ",
|
||||||
|
"Å": "Ṛ",
|
||||||
|
"ñ": "ṣ",
|
||||||
|
"Ñ": "Ṣ",
|
||||||
|
"ö": "ṭ",
|
||||||
|
"Ö": "Ṭ",
|
||||||
|
"ë": "ṇ",
|
||||||
|
"Ë": "Ṇ",
|
||||||
|
"ò": "ḍ",
|
||||||
|
"Ò": "Ḍ",
|
||||||
|
"ç": "ś",
|
||||||
|
"Ç": "Ś",
|
||||||
|
"ì": "ṅ",
|
||||||
|
"Ì": "Ṅ",
|
||||||
|
"ï": "ñ",
|
||||||
|
"Ï": "Ñ",
|
||||||
|
"ü": "ū",
|
||||||
|
"Ü": "Ū",
|
||||||
|
"é": "ī",
|
||||||
|
"É": "Ī",
|
||||||
|
"à": "ṁ",
|
||||||
|
"ù": "ḥ",
|
||||||
|
"è": "ṝ",
|
||||||
|
"ÿ": "ḷ",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_scagoudy(text: str) -> str:
|
||||||
|
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||||
|
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||||
|
text = text.replace(src, dst)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def get_css_class(tag: Tag) -> str:
|
||||||
|
classes = tag.get("class", [])
|
||||||
|
return classes[0] if classes else ""
|
||||||
|
|
||||||
|
|
||||||
|
def inner_html(tag: Tag) -> str:
|
||||||
|
"""Get inner HTML of a tag, decode ScaGoudy, clean up spans."""
|
||||||
|
html = "".join(str(child) for child in tag.children)
|
||||||
|
html = decode_scagoudy(html)
|
||||||
|
|
||||||
|
# <span class="s2">text</span> → <i>text</i> (italic marker in btg-1978)
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s2"[^>]*>(.*?)</span>', r"<i>\1</i>", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove all other span tags but keep inner content
|
||||||
|
html = re.sub(r"<span[^>]*>(.*?)</span>", r"\1", html, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Remove Apple-tab-span and tab characters
|
||||||
|
html = html.replace("\t", " ")
|
||||||
|
|
||||||
|
# Collapse multiple spaces (but not around <br>)
|
||||||
|
html = re.sub(r"[ ]+", " ", html)
|
||||||
|
html = html.strip()
|
||||||
|
|
||||||
|
# Remove leading <br> tags
|
||||||
|
html = re.sub(r"^(<br\s*/?>[\s]*)+", "", html)
|
||||||
|
# Remove trailing <br> tags
|
||||||
|
html = re.sub(r"(<br\s*/?>[\s]*)+$", "", html)
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(data: dict, path: str):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parse issue info from p2 text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_issue_info(text: str):
|
||||||
|
"""Parse issue info from the p2 bookmark text.
|
||||||
|
|
||||||
|
Handles various formats:
|
||||||
|
"Back to Godhead Magazine. Vol. 1, No. 01, October 23, 1966"
|
||||||
|
"Back to Godhead Magazine. Vol. 1, No. 06, January 20, 1967"
|
||||||
|
"Back to Godhead Magazine. [VOL II No 1.] No. 16, 1968"
|
||||||
|
"Back to Godhead Magazine VOL II. No 2. [#17], 1968"
|
||||||
|
"Back to Godhead Magazine no. 30, 1969"
|
||||||
|
"Back to Godhead Magazine No. 36, 1970"
|
||||||
|
|
||||||
|
Returns (issue_number, volume, display_title, date_str, slug) or None.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
# Remove "Back to Godhead Magazine" prefix variations
|
||||||
|
text_clean = re.sub(
|
||||||
|
r"^Back to Godhead Magazine\.?\s*", "", text, flags=re.IGNORECASE
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
issue_num = None
|
||||||
|
volume = None
|
||||||
|
date_str = ""
|
||||||
|
|
||||||
|
# Try to extract issue number from various patterns
|
||||||
|
|
||||||
|
# Pattern 1: "Vol. X, No. YY, date" or "Vol. X No. YY, date"
|
||||||
|
m = re.search(
|
||||||
|
r"Vol\.?\s*(\d+)\.?\s*,?\s*No\.?\s*(\d+)\s*,?\s*(.*)", text_clean, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
volume = int(m.group(1))
|
||||||
|
issue_num = int(m.group(2))
|
||||||
|
date_str = m.group(3).strip().rstrip(",. ")
|
||||||
|
|
||||||
|
# Pattern 2: "[VOL II No 1.] No. 16, 1968" — use the absolute number
|
||||||
|
if issue_num is None:
|
||||||
|
m = re.search(r"No\.?\s*(\d+)\s*,?\s*(\d{4})", text_clean, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
issue_num = int(m.group(1))
|
||||||
|
date_str = m.group(2).strip()
|
||||||
|
|
||||||
|
# Pattern 3: "VOL II. No 2. [#17], 1968"
|
||||||
|
if issue_num is None:
|
||||||
|
m = re.search(r"#(\d+)", text_clean)
|
||||||
|
if m:
|
||||||
|
issue_num = int(m.group(1))
|
||||||
|
|
||||||
|
# Pattern 4: "no. 30, 1969" or "no 31, 1969"
|
||||||
|
if issue_num is None:
|
||||||
|
m = re.search(r"no\.?\s*(\d+)\s*,?\s*(.*)", text_clean, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
issue_num = int(m.group(1))
|
||||||
|
date_str = m.group(2).strip().rstrip(",. ")
|
||||||
|
|
||||||
|
# Try to extract volume from text if not found yet
|
||||||
|
if volume is None:
|
||||||
|
m = re.search(r"VOL\.?\s*(?:II|2)", text_clean, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
volume = 2
|
||||||
|
else:
|
||||||
|
m = re.search(r"VOL\.?\s*(?:I|1)", text_clean, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
volume = 1
|
||||||
|
|
||||||
|
if issue_num is None:
|
||||||
|
print(f" ⚠ Could not parse issue number from: {text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract year from date_str if present
|
||||||
|
year_match = re.search(r"(\d{4})", date_str)
|
||||||
|
year = int(year_match.group(1)) if year_match else None
|
||||||
|
|
||||||
|
# Build display title
|
||||||
|
if volume and issue_num <= 17:
|
||||||
|
display_title = f"Vol. {volume}, No. {issue_num:02d}"
|
||||||
|
if date_str:
|
||||||
|
display_title += f", {date_str}"
|
||||||
|
else:
|
||||||
|
display_title = f"No. {issue_num}"
|
||||||
|
if date_str:
|
||||||
|
display_title += f", {date_str}"
|
||||||
|
|
||||||
|
slug = f"no-{issue_num:02d}"
|
||||||
|
|
||||||
|
return issue_num, volume, display_title, date_str, year, slug
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main extraction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
html_path = os.path.join(base_dir, "public", "html", "btg-1978.html")
|
||||||
|
output_dir = os.path.join(base_dir, "lib", "jsons", "magazines", "btg-1978")
|
||||||
|
issues_dir = os.path.join(output_dir, "issues")
|
||||||
|
|
||||||
|
if not os.path.exists(html_path):
|
||||||
|
print(f"ERROR: {html_path} not found!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Reading {html_path}...")
|
||||||
|
with open(html_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
all_tags = soup.find_all("p")
|
||||||
|
print(f" Parsed {len(all_tags)} <p> tags total")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1: Find all issue boundaries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
issue_boundaries = [] # List of (tag_index, issue_num, volume, display_title, date_str, year, slug)
|
||||||
|
|
||||||
|
for i, tag in enumerate(all_tags):
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
if cls == "p2":
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
if "Back to Godhead" in text:
|
||||||
|
parsed = parse_issue_info(text)
|
||||||
|
if parsed:
|
||||||
|
issue_num, volume, display_title, date_str, year, slug = parsed
|
||||||
|
issue_boundaries.append(
|
||||||
|
(i, issue_num, volume, display_title, date_str, year, slug)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n Found {len(issue_boundaries)} issues")
|
||||||
|
for ib in issue_boundaries:
|
||||||
|
tag_idx, inum, vol, title, ds, yr, slug = ib
|
||||||
|
print(f" tag[{tag_idx}]: #{inum} (Vol {vol}) {title} → {slug}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2: Extract articles and paragraphs for each issue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Known skip classes (bookmarks, empty lines)
|
||||||
|
SKIP_CLASSES = {"p2", "p11", "p28", "p24"}
|
||||||
|
|
||||||
|
issues_data = []
|
||||||
|
skipped_ads = 0
|
||||||
|
|
||||||
|
# Maximum paragraphs per issue — prevents mega-issues from absorbing
|
||||||
|
# unmarked content that belongs to missing intermediate issues.
|
||||||
|
MAX_PARAGRAPHS_PER_ISSUE = 500
|
||||||
|
|
||||||
|
for idx, (
|
||||||
|
start_tag_idx,
|
||||||
|
issue_num,
|
||||||
|
vol,
|
||||||
|
display_title,
|
||||||
|
date_str,
|
||||||
|
year,
|
||||||
|
slug,
|
||||||
|
) in enumerate(issue_boundaries):
|
||||||
|
# Determine end boundary
|
||||||
|
if idx + 1 < len(issue_boundaries):
|
||||||
|
end_tag_idx = issue_boundaries[idx + 1][0]
|
||||||
|
else:
|
||||||
|
end_tag_idx = len(all_tags)
|
||||||
|
|
||||||
|
issue_tags = all_tags[start_tag_idx:end_tag_idx]
|
||||||
|
|
||||||
|
paragraphs = []
|
||||||
|
articles = []
|
||||||
|
in_advertisement = False
|
||||||
|
|
||||||
|
for tag in issue_tags:
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
|
||||||
|
# Skip bookmark tags and empty lines
|
||||||
|
if cls in SKIP_CLASSES:
|
||||||
|
continue
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p8 = Article bookmark
|
||||||
|
if cls == "p8":
|
||||||
|
article_title = decode_scagoudy(text).strip()
|
||||||
|
|
||||||
|
# Check if this is an advertisement
|
||||||
|
if re.match(r"^Advertisements?", article_title, re.IGNORECASE):
|
||||||
|
in_advertisement = True
|
||||||
|
skipped_ads += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
in_advertisement = False
|
||||||
|
articles.append({"title": article_title})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p122 and other classes can also be advertisement markers
|
||||||
|
if re.match(r"^Advertisements?\s*$", text, re.IGNORECASE):
|
||||||
|
in_advertisement = True
|
||||||
|
skipped_ads += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p9 = Article display title
|
||||||
|
if cls == "p9":
|
||||||
|
if in_advertisement:
|
||||||
|
continue
|
||||||
|
# Also check the p9 display title itself for advertisement
|
||||||
|
plain_title = decode_scagoudy(text).strip()
|
||||||
|
if re.match(r"^Advertisements?\s*$", plain_title, re.IGNORECASE):
|
||||||
|
in_advertisement = True
|
||||||
|
skipped_ads += 1
|
||||||
|
continue
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
# Make it a bold article heading if not already
|
||||||
|
if not html.startswith("<b>"):
|
||||||
|
html = f"<b>{html}</b>"
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip content within advertisement sections
|
||||||
|
if in_advertisement:
|
||||||
|
# Check if next p8 will reset it — for now just skip
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p15 = Verse block — center align
|
||||||
|
if cls == "p15":
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(f'<div style="text-align:center">{html}</div>')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All other classes — extract as body text
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html and html != "<br>":
|
||||||
|
# Check if this looks like a verse (entirely italic with <br> breaks)
|
||||||
|
stripped = html.strip()
|
||||||
|
if (
|
||||||
|
stripped.startswith("<i>")
|
||||||
|
and "<br" in stripped
|
||||||
|
and '<div style="text-align:center">' not in stripped
|
||||||
|
):
|
||||||
|
plain = re.sub(r"</?[bi]>", "", stripped)
|
||||||
|
lines = [
|
||||||
|
l.strip() for l in re.split(r"<br\s*/?>", plain) if l.strip()
|
||||||
|
]
|
||||||
|
if len(lines) >= 2:
|
||||||
|
html = f'<div style="text-align:center">{stripped}</div>'
|
||||||
|
|
||||||
|
paragraphs.append(html)
|
||||||
|
|
||||||
|
# Post-process: remove any ad-like paragraphs that slipped through.
|
||||||
|
# Two-pass approach:
|
||||||
|
# 1. Detect ad section start markers and skip until next real article
|
||||||
|
# 2. Filter individual ad-like paragraphs by content patterns
|
||||||
|
|
||||||
|
# --- Pass 1: Section-level ad detection ---
|
||||||
|
ad_section_patterns = [
|
||||||
|
r"^Advertisements?\s*$",
|
||||||
|
r"^CIRCULATION AND SUBSCRIPTIONS",
|
||||||
|
r"^Order Form",
|
||||||
|
r"^SPECIAL OFFER",
|
||||||
|
r"^Subscriptions?:",
|
||||||
|
r"^WORLDWIDE IMPORTS",
|
||||||
|
r"^THE\s+ORIENT\s+SHOP",
|
||||||
|
r"^PROTECT YOUR CHECKS",
|
||||||
|
]
|
||||||
|
ad_section_re = re.compile("|".join(ad_section_patterns), re.IGNORECASE)
|
||||||
|
|
||||||
|
cleaned_pass1 = []
|
||||||
|
skip_until_next_bold = False
|
||||||
|
for p in paragraphs:
|
||||||
|
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||||
|
# Detect ad section start
|
||||||
|
if ad_section_re.search(plain):
|
||||||
|
skip_until_next_bold = True
|
||||||
|
continue
|
||||||
|
if skip_until_next_bold:
|
||||||
|
# A bold article heading that's NOT an ad resets the skip
|
||||||
|
if p.startswith("<b>") and len(plain) < 200:
|
||||||
|
if ad_section_re.search(plain):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
skip_until_next_bold = False
|
||||||
|
cleaned_pass1.append(p)
|
||||||
|
continue
|
||||||
|
# If it looks like real content (long paragraph), stop skipping
|
||||||
|
if len(plain) > 150:
|
||||||
|
skip_until_next_bold = False
|
||||||
|
cleaned_pass1.append(p)
|
||||||
|
continue
|
||||||
|
# Short paragraphs in ad zone — skip
|
||||||
|
continue
|
||||||
|
cleaned_pass1.append(p)
|
||||||
|
|
||||||
|
# --- Pass 2: Individual paragraph ad detection ---
|
||||||
|
# These patterns catch standalone ad fragments that weren't part of
|
||||||
|
# a marked advertisement section.
|
||||||
|
ad_para_keywords = [
|
||||||
|
"subscription",
|
||||||
|
"per copy",
|
||||||
|
"enclosed is",
|
||||||
|
"please send me",
|
||||||
|
"order form",
|
||||||
|
"postage paid",
|
||||||
|
"money order",
|
||||||
|
"check or money order",
|
||||||
|
"zip code",
|
||||||
|
"state_____",
|
||||||
|
"address_____",
|
||||||
|
"city_____",
|
||||||
|
"available from iskcon",
|
||||||
|
"waterpipes",
|
||||||
|
"djellabas",
|
||||||
|
"panchos",
|
||||||
|
"orient shop",
|
||||||
|
"worldwide imports",
|
||||||
|
"sullivan st",
|
||||||
|
"bleecker",
|
||||||
|
"check protector",
|
||||||
|
"safeguards your checks",
|
||||||
|
"satisfaction guaranteed",
|
||||||
|
"import-export",
|
||||||
|
"davis & manswell",
|
||||||
|
"davis & manswell",
|
||||||
|
"bedford ave",
|
||||||
|
"brooklyn, n.y.",
|
||||||
|
"immediate delivery",
|
||||||
|
"free gift guide",
|
||||||
|
"write for free",
|
||||||
|
"free catalog",
|
||||||
|
"free catalogue",
|
||||||
|
"protect your checks",
|
||||||
|
"name of purchaser",
|
||||||
|
"such dresses at $",
|
||||||
|
"and gowns yet $",
|
||||||
|
"price per copy",
|
||||||
|
"one-year subscription",
|
||||||
|
"year subscription costs",
|
||||||
|
"back issues available",
|
||||||
|
"a subscription to",
|
||||||
|
"send check or money",
|
||||||
|
"your name & address",
|
||||||
|
"your name & address",
|
||||||
|
"674-9511",
|
||||||
|
]
|
||||||
|
# Also detect short paragraphs that look like addresses or phone numbers
|
||||||
|
address_re = re.compile(
|
||||||
|
r"^\d+\s+\w+\s+(st|street|ave|avenue|blvd|road|rd|lane|ln|dr|drive)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
phone_re = re.compile(r"^\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\s*$")
|
||||||
|
zipcode_re = re.compile(
|
||||||
|
r"(N\.?Y\.?\s+\d{5}|CA\s+\d{5}|Brooklyn|Manhattan)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
cleaned = []
|
||||||
|
for p in cleaned_pass1:
|
||||||
|
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||||
|
plain_lower = plain.lower()
|
||||||
|
|
||||||
|
# Skip if matches any ad keyword
|
||||||
|
if any(kw in plain_lower for kw in ad_para_keywords):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip short paragraphs that look like addresses
|
||||||
|
if len(plain) < 80 and address_re.search(plain):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip standalone phone numbers
|
||||||
|
if phone_re.match(plain):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip short paragraphs with zip codes (likely address fragments)
|
||||||
|
if len(plain) < 60 and zipcode_re.search(plain):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip price-only paragraphs like "$1 plus 25c"
|
||||||
|
if re.match(
|
||||||
|
r"^\$?\d+[\.\d]*\s*(plus|postage|each|per)", plain, re.IGNORECASE
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleaned.append(p)
|
||||||
|
paragraphs = cleaned
|
||||||
|
|
||||||
|
# Cap paragraphs to prevent mega-issues from absorbing unmarked
|
||||||
|
# intermediate issues (e.g., issues 18-29 have no p2 markers and
|
||||||
|
# would otherwise be absorbed into issue 17).
|
||||||
|
if len(paragraphs) > MAX_PARAGRAPHS_PER_ISSUE:
|
||||||
|
print(
|
||||||
|
f" ⚠ {slug}: Capped from {len(paragraphs)} to {MAX_PARAGRAPHS_PER_ISSUE} paragraphs "
|
||||||
|
f"(likely absorbed unmarked intermediate issues)"
|
||||||
|
)
|
||||||
|
paragraphs = paragraphs[:MAX_PARAGRAPHS_PER_ISSUE]
|
||||||
|
|
||||||
|
issue_data = {
|
||||||
|
"book": "btg-1978",
|
||||||
|
"volume": vol,
|
||||||
|
"part": issue_num,
|
||||||
|
"title": display_title,
|
||||||
|
"slug": slug,
|
||||||
|
"year": year,
|
||||||
|
"articles": articles,
|
||||||
|
"paragraphs": paragraphs,
|
||||||
|
}
|
||||||
|
issues_data.append(issue_data)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {slug}: {display_title} — "
|
||||||
|
f"{len(articles)} articles, {len(paragraphs)} paragraphs"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n Skipped {skipped_ads} advertisement sections")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3: Build intro.json with hierarchical groups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Group issues by era/volume
|
||||||
|
# Early issues (1-17) have Vol 1/Vol 2 designations
|
||||||
|
# Later issues (30+) just have issue numbers
|
||||||
|
group_early = [] # Issues 1-17 (1966-1968)
|
||||||
|
group_later = [] # Issues 30+ (1969-1973)
|
||||||
|
|
||||||
|
for issue in issues_data:
|
||||||
|
if issue["part"] <= 17:
|
||||||
|
group_early.append(issue)
|
||||||
|
else:
|
||||||
|
group_later.append(issue)
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
|
||||||
|
if group_early:
|
||||||
|
sub = []
|
||||||
|
for issue in group_early:
|
||||||
|
sub.append({"slug": issue["slug"], "title": issue["title"]})
|
||||||
|
chapters.append(
|
||||||
|
{
|
||||||
|
"title": "Issues #1–17 (1966–1968)",
|
||||||
|
"subChapters": sub,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_later:
|
||||||
|
sub = []
|
||||||
|
for issue in group_later:
|
||||||
|
sub.append({"slug": issue["slug"], "title": issue["title"]})
|
||||||
|
chapters.append(
|
||||||
|
{
|
||||||
|
"title": "Issues #30–54 (1969–1973)",
|
||||||
|
"subChapters": sub,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
total_issues = len(issues_data)
|
||||||
|
total_articles = sum(len(i["articles"]) for i in issues_data)
|
||||||
|
|
||||||
|
# Determine year range
|
||||||
|
years = [i["year"] for i in issues_data if i["year"]]
|
||||||
|
min_year = min(years) if years else None
|
||||||
|
max_year = max(years) if years else None
|
||||||
|
|
||||||
|
intro_json = {
|
||||||
|
"book": "btg-1978",
|
||||||
|
"title": "Back to Godhead (1966–1973)",
|
||||||
|
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||||
|
"year": min_year,
|
||||||
|
"description": (
|
||||||
|
f"Back to Godhead magazine issues from the Western era ({min_year}–{max_year}). "
|
||||||
|
f"{total_issues} issues containing {total_articles} articles — lectures, essays, "
|
||||||
|
f"poems, and philosophical discussions on Kṛṣṇa consciousness."
|
||||||
|
),
|
||||||
|
"totalIssues": total_issues,
|
||||||
|
"totalArticles": total_articles,
|
||||||
|
"flag": "Pre 1978",
|
||||||
|
"chapters": chapters,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 4: Write JSON files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Clean output directory
|
||||||
|
if os.path.exists(issues_dir):
|
||||||
|
for f in os.listdir(issues_dir):
|
||||||
|
os.remove(os.path.join(issues_dir, f))
|
||||||
|
|
||||||
|
# Write intro.json
|
||||||
|
write_json(intro_json, os.path.join(output_dir, "intro.json"))
|
||||||
|
print(f"\n Written: intro.json ({total_issues} issues, {total_articles} articles)")
|
||||||
|
|
||||||
|
# Write issue files
|
||||||
|
order = 100
|
||||||
|
for issue in issues_data:
|
||||||
|
issue_json = {
|
||||||
|
"book": "btg-1978",
|
||||||
|
"volume": issue["volume"],
|
||||||
|
"part": issue["part"],
|
||||||
|
"title": issue["title"],
|
||||||
|
"slug": issue["slug"],
|
||||||
|
"order": order,
|
||||||
|
"articles": issue["articles"],
|
||||||
|
"paragraphs": issue["paragraphs"],
|
||||||
|
}
|
||||||
|
filepath = os.path.join(issues_dir, f"{issue['slug']}.json")
|
||||||
|
write_json(issue_json, filepath)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
print(f" Written: {len(issues_data)} issue files")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 5: Quality checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" QUALITY CHECKS")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
# Check for ScaGoudy remnants
|
||||||
|
sca_chars = set(SCAGOUDY_TO_IAST.keys())
|
||||||
|
sca_remnants = 0
|
||||||
|
for issue in issues_data:
|
||||||
|
for p in issue["paragraphs"]:
|
||||||
|
if any(c in p for c in ("å", "ë", "ö")):
|
||||||
|
sca_remnants += 1
|
||||||
|
break
|
||||||
|
if sca_remnants == 0:
|
||||||
|
print(f" ✅ No ScaGoudy remnants detected")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ {sca_remnants} issues may have ScaGoudy remnants")
|
||||||
|
|
||||||
|
# Check for empty issues
|
||||||
|
empty_issues = [i for i in issues_data if len(i["paragraphs"]) == 0]
|
||||||
|
if empty_issues:
|
||||||
|
print(
|
||||||
|
f" ⚠ {len(empty_issues)} empty issues: {[i['slug'] for i in empty_issues]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" ✅ No empty issues")
|
||||||
|
|
||||||
|
# Check centered verses
|
||||||
|
verse_count = 0
|
||||||
|
for issue in issues_data:
|
||||||
|
for p in issue["paragraphs"]:
|
||||||
|
if '<div style="text-align:center">' in p:
|
||||||
|
verse_count += 1
|
||||||
|
print(f" ℹ️ {verse_count} center-aligned verse paragraphs")
|
||||||
|
|
||||||
|
# Check for remaining advertisement content
|
||||||
|
ad_check = 0
|
||||||
|
ad_keywords = ["advertisement", "subscribe", "subscription form"]
|
||||||
|
for issue in issues_data:
|
||||||
|
for p in issue["paragraphs"]:
|
||||||
|
lower_p = p.lower()
|
||||||
|
if any(kw in lower_p for kw in ad_keywords):
|
||||||
|
# Only flag if the paragraph is primarily an ad
|
||||||
|
if "advertisement" in lower_p and len(p) < 200:
|
||||||
|
ad_check += 1
|
||||||
|
if ad_check == 0:
|
||||||
|
print(f" ✅ No advertisement content detected in output")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ {ad_check} paragraphs may contain advertisement content")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" EXTRACTION SUMMARY")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
if group_early:
|
||||||
|
early_arts = sum(len(i["articles"]) for i in group_early)
|
||||||
|
early_paras = sum(len(i["paragraphs"]) for i in group_early)
|
||||||
|
print(
|
||||||
|
f" Issues #1–17 (1966–1968): {len(group_early)} issues, "
|
||||||
|
f"{early_arts} articles, {early_paras} paragraphs"
|
||||||
|
)
|
||||||
|
for issue in group_early:
|
||||||
|
print(
|
||||||
|
f" {issue['slug']}: {issue['title']} "
|
||||||
|
f"({len(issue['articles'])} articles, {len(issue['paragraphs'])} paras)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_later:
|
||||||
|
later_arts = sum(len(i["articles"]) for i in group_later)
|
||||||
|
later_paras = sum(len(i["paragraphs"]) for i in group_later)
|
||||||
|
print(
|
||||||
|
f" Issues #30–54 (1969–1973): {len(group_later)} issues, "
|
||||||
|
f"{later_arts} articles, {later_paras} paragraphs"
|
||||||
|
)
|
||||||
|
for issue in group_later:
|
||||||
|
print(
|
||||||
|
f" {issue['slug']}: {issue['title']} "
|
||||||
|
f"({len(issue['articles'])} articles, {len(issue['paragraphs'])} paras)"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n Total: {total_issues} issues, {total_articles} articles")
|
||||||
|
print(f" Skipped: {skipped_ads} advertisement sections")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"\nDone! ✨")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract Back to Godhead magazine from btg-1960.html.
|
||||||
|
|
||||||
|
Each magazine issue becomes its own JSON file.
|
||||||
|
Issues are grouped by volume in the intro.json metadata.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- p2 = Volume/Part bookmark (e.g., "BTGPY1: Volume 1, Part 1, 1944")
|
||||||
|
- p3 = Volume/Part display title
|
||||||
|
- p8 = Article bookmark within an issue (e.g., "BTGPY1a: Message of His Divine Grace")
|
||||||
|
- p9 = Article display title (bold)
|
||||||
|
- p12, p35 = Body paragraphs
|
||||||
|
- p14, p17, p88 = Continuation notes / misc text
|
||||||
|
- p15 = Verse blocks (italic, with ScaGoudy encoding)
|
||||||
|
- p33, p40, p47, p54 = Magazine mastheads/headers
|
||||||
|
|
||||||
|
Output:
|
||||||
|
lib/jsons/magazines/btg/intro.json
|
||||||
|
lib/jsons/magazines/btg/issues/v1-p1.json
|
||||||
|
lib/jsons/magazines/btg/issues/v1-p2.json
|
||||||
|
...
|
||||||
|
lib/jsons/magazines/btg/issues/v4-p1.json
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/btg_extract.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ScaGoudy → IAST Unicode mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCAGOUDY_TO_IAST = {
|
||||||
|
"ä": "ā",
|
||||||
|
"Ä": "Ā",
|
||||||
|
"å": "ṛ",
|
||||||
|
"Å": "Ṛ",
|
||||||
|
"ñ": "ṣ",
|
||||||
|
"Ñ": "Ṣ",
|
||||||
|
"ö": "ṭ",
|
||||||
|
"Ö": "Ṭ",
|
||||||
|
"ë": "ṇ",
|
||||||
|
"Ë": "Ṇ",
|
||||||
|
"ò": "ḍ",
|
||||||
|
"Ò": "Ḍ",
|
||||||
|
"ç": "ś",
|
||||||
|
"Ç": "Ś",
|
||||||
|
"ì": "ṅ",
|
||||||
|
"Ì": "Ṅ",
|
||||||
|
"ï": "ñ",
|
||||||
|
"Ï": "Ñ",
|
||||||
|
"ü": "ū",
|
||||||
|
"Ü": "Ū",
|
||||||
|
"é": "ī",
|
||||||
|
"É": "Ī",
|
||||||
|
"à": "ṁ",
|
||||||
|
"ù": "ḥ",
|
||||||
|
"è": "ṝ",
|
||||||
|
"ÿ": "ḷ",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_scagoudy(text: str) -> str:
|
||||||
|
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||||
|
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||||
|
text = text.replace(src, dst)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def get_css_class(tag: Tag) -> str:
|
||||||
|
classes = tag.get("class", [])
|
||||||
|
return classes[0] if classes else ""
|
||||||
|
|
||||||
|
|
||||||
|
def inner_html(tag: Tag) -> str:
|
||||||
|
"""Get inner HTML of a tag, decode ScaGoudy, clean up spans."""
|
||||||
|
html = "".join(str(child) for child in tag.children)
|
||||||
|
html = decode_scagoudy(html)
|
||||||
|
# <span class="s1">text</span> → keep inner text (s1 is a ref style)
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s1"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# <span class="s3">text</span> → keep inner text (superscript digit)
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s3"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# <span class="s4">text</span> → keep inner text
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s4"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# <span class="s5">text</span> → keep inner text
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s5"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# <span class="s7">text</span> → keep inner text
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s7"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# <span class="s8">text</span> → keep inner text
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s8"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# Remove any remaining spans but keep content
|
||||||
|
html = re.sub(r"<span[^>]*>(.*?)</span>", r"\1", html, flags=re.DOTALL)
|
||||||
|
# Remove Apple-tab-span
|
||||||
|
html = html.replace("\t", " ")
|
||||||
|
# Collapse multiple spaces (but not around <br>)
|
||||||
|
html = re.sub(r"[ ]+", " ", html)
|
||||||
|
html = html.strip()
|
||||||
|
# Remove leading <br> tags
|
||||||
|
html = re.sub(r"^(<br\s*/?>[\s]*)+", "", html)
|
||||||
|
# Remove trailing <br> tags
|
||||||
|
html = re.sub(r"(<br\s*/?>[\s]*)+$", "", html)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def is_verse_paragraph(tag: Tag) -> bool:
|
||||||
|
"""Check if a paragraph is a verse block (italic with line breaks)."""
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
# p15 is commonly used for verse blocks
|
||||||
|
if cls == "p15":
|
||||||
|
return True
|
||||||
|
# Also check for paragraphs that are entirely italic with <br> breaks
|
||||||
|
# (some verses use p14 or other classes but are entirely in <i> tags)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def format_verse(tag: Tag) -> str:
|
||||||
|
"""Format a verse paragraph as a center-aligned div."""
|
||||||
|
html = inner_html(tag)
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
# Wrap in center-aligned div
|
||||||
|
return f'<div style="text-align:center">{html}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(data: dict, path: str):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parse volume/part info from p2 bookmark text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_volume_part(text: str):
|
||||||
|
"""Parse 'BTGPY1: Volume 1, Part 1, 1944' → (volume, part, date_str, slug).
|
||||||
|
Returns (volume_num, part_num, date_string, slug) or None.
|
||||||
|
"""
|
||||||
|
# Remove the BTGPY prefix
|
||||||
|
text = text.strip()
|
||||||
|
# Match "Volume X, Part Y, ..."
|
||||||
|
m = re.search(r"Volume\s+(\d+),?\s*Part\s+(\d+),?\s*(.*)", text, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
vol = int(m.group(1))
|
||||||
|
part = int(m.group(2))
|
||||||
|
date_str = m.group(3).strip().rstrip(" ")
|
||||||
|
slug = f"v{vol}-p{part}"
|
||||||
|
return vol, part, date_str, slug
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main extraction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
html_path = os.path.join(base_dir, "public", "html", "btg-1960.html")
|
||||||
|
output_dir = os.path.join(base_dir, "lib", "jsons", "magazines", "btg")
|
||||||
|
issues_dir = os.path.join(output_dir, "issues")
|
||||||
|
|
||||||
|
if not os.path.exists(html_path):
|
||||||
|
print(f"ERROR: {html_path} not found!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Reading {html_path}...")
|
||||||
|
with open(html_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
all_tags = soup.find_all("p")
|
||||||
|
print(f" Parsed {len(all_tags)} <p> tags total")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1: Find all issue boundaries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Each issue starts with a p2 bookmark tag
|
||||||
|
issue_boundaries = [] # List of (tag_index, volume, part, date_str, slug)
|
||||||
|
|
||||||
|
for i, tag in enumerate(all_tags):
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
if cls == "p2":
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
parsed = parse_volume_part(text)
|
||||||
|
if parsed:
|
||||||
|
vol, part, date_str, slug = parsed
|
||||||
|
issue_boundaries.append((i, vol, part, date_str, slug))
|
||||||
|
|
||||||
|
print(f"\n Found {len(issue_boundaries)} issues")
|
||||||
|
for ib in issue_boundaries:
|
||||||
|
print(f" tag[{ib[0]}]: Vol {ib[1]}, Part {ib[2]}, {ib[3]} → {ib[4]}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2: Extract articles and paragraphs for each issue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Classes to skip entirely
|
||||||
|
SKIP_CLASSES = {"p2", "p11", "p24", "p28"}
|
||||||
|
|
||||||
|
# Classes that are header/masthead material (include as paragraphs)
|
||||||
|
MASTHEAD_CLASSES = {"p3", "p4", "p5", "p6", "p7", "p32", "p33", "p34", "p40", "p47"}
|
||||||
|
|
||||||
|
# Classes that are body text
|
||||||
|
BODY_CLASSES = {
|
||||||
|
"p10",
|
||||||
|
"p12",
|
||||||
|
"p13",
|
||||||
|
"p14",
|
||||||
|
"p15",
|
||||||
|
"p16",
|
||||||
|
"p17",
|
||||||
|
"p35",
|
||||||
|
"p36",
|
||||||
|
"p37",
|
||||||
|
"p38",
|
||||||
|
"p39",
|
||||||
|
"p41",
|
||||||
|
"p42",
|
||||||
|
"p43",
|
||||||
|
"p44",
|
||||||
|
"p45",
|
||||||
|
"p46",
|
||||||
|
"p48",
|
||||||
|
"p49",
|
||||||
|
"p50",
|
||||||
|
"p51",
|
||||||
|
"p52",
|
||||||
|
"p53",
|
||||||
|
"p54",
|
||||||
|
"p55",
|
||||||
|
"p56",
|
||||||
|
"p57",
|
||||||
|
"p58",
|
||||||
|
"p59",
|
||||||
|
"p60",
|
||||||
|
"p61",
|
||||||
|
"p62",
|
||||||
|
"p63",
|
||||||
|
"p64",
|
||||||
|
"p65",
|
||||||
|
"p66",
|
||||||
|
"p67",
|
||||||
|
"p68",
|
||||||
|
"p69",
|
||||||
|
"p70",
|
||||||
|
"p71",
|
||||||
|
"p72",
|
||||||
|
"p73",
|
||||||
|
"p74",
|
||||||
|
"p75",
|
||||||
|
"p76",
|
||||||
|
"p77",
|
||||||
|
"p78",
|
||||||
|
"p79",
|
||||||
|
"p80",
|
||||||
|
"p81",
|
||||||
|
"p82",
|
||||||
|
"p83",
|
||||||
|
"p84",
|
||||||
|
"p85",
|
||||||
|
"p86",
|
||||||
|
"p87",
|
||||||
|
"p88",
|
||||||
|
"p89",
|
||||||
|
"p90",
|
||||||
|
"p91",
|
||||||
|
"p92",
|
||||||
|
"p93",
|
||||||
|
"p94",
|
||||||
|
"p95",
|
||||||
|
"p96",
|
||||||
|
"p97",
|
||||||
|
"p98",
|
||||||
|
"p99",
|
||||||
|
}
|
||||||
|
|
||||||
|
issues_data = []
|
||||||
|
|
||||||
|
for idx, (start_tag_idx, vol, part, date_str, slug) in enumerate(issue_boundaries):
|
||||||
|
# Determine end boundary
|
||||||
|
if idx + 1 < len(issue_boundaries):
|
||||||
|
end_tag_idx = issue_boundaries[idx + 1][0]
|
||||||
|
else:
|
||||||
|
end_tag_idx = len(all_tags)
|
||||||
|
|
||||||
|
issue_tags = all_tags[start_tag_idx:end_tag_idx]
|
||||||
|
|
||||||
|
paragraphs = []
|
||||||
|
articles = [] # List of { title, slug }
|
||||||
|
|
||||||
|
for tag in issue_tags:
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
|
||||||
|
# Skip bookmark tags and empty lines
|
||||||
|
if cls in SKIP_CLASSES:
|
||||||
|
continue
|
||||||
|
if not text and cls not in ("p15",):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p8 = Article bookmark — extract title for metadata
|
||||||
|
if cls == "p8":
|
||||||
|
# Extract article title from bookmark text (remove BTGPYxx: prefix)
|
||||||
|
article_text = text
|
||||||
|
m = re.match(r"BTGPY\d+[a-z]?:\s*(.*)", article_text)
|
||||||
|
if m:
|
||||||
|
article_title = m.group(1).strip()
|
||||||
|
else:
|
||||||
|
article_title = article_text
|
||||||
|
article_title = decode_scagoudy(article_title)
|
||||||
|
articles.append({"title": article_title})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p9 = Article display title — render as bold heading
|
||||||
|
if cls == "p9":
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
# Make it a bold article heading
|
||||||
|
if not html.startswith("<b>"):
|
||||||
|
html = f"<b>{html}</b>"
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p15 = Verse block — center align
|
||||||
|
if cls == "p15":
|
||||||
|
html = format_verse(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p3 = Volume title — skip (it's the display title of the issue header)
|
||||||
|
if cls == "p3":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Masthead classes — include but as regular paragraphs
|
||||||
|
if cls in MASTHEAD_CLASSES:
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html and html != "<br>":
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Body text classes
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html and html != "<br>":
|
||||||
|
# Check if this looks like a verse (entirely italic with <br> breaks)
|
||||||
|
stripped = html.strip()
|
||||||
|
if (
|
||||||
|
stripped.startswith("<i>")
|
||||||
|
and "<br" in stripped
|
||||||
|
and not '<div style="text-align:center">' in stripped
|
||||||
|
):
|
||||||
|
# Check if it's predominantly a verse (italic block with line breaks)
|
||||||
|
# by seeing if removing the <i> tags leaves mostly text with <br>
|
||||||
|
plain = re.sub(r"</?[bi]>", "", stripped)
|
||||||
|
lines = [
|
||||||
|
l.strip() for l in re.split(r"<br\s*/?>", plain) if l.strip()
|
||||||
|
]
|
||||||
|
if len(lines) >= 2:
|
||||||
|
html = f'<div style="text-align:center">{stripped}</div>'
|
||||||
|
|
||||||
|
paragraphs.append(html)
|
||||||
|
|
||||||
|
# Build display title
|
||||||
|
if date_str:
|
||||||
|
display_title = f"Volume {vol}, Part {part}, {date_str}"
|
||||||
|
else:
|
||||||
|
display_title = f"Volume {vol}, Part {part}"
|
||||||
|
|
||||||
|
issue_data = {
|
||||||
|
"book": "btg",
|
||||||
|
"volume": vol,
|
||||||
|
"part": part,
|
||||||
|
"title": display_title,
|
||||||
|
"slug": slug,
|
||||||
|
"articles": articles,
|
||||||
|
"paragraphs": paragraphs,
|
||||||
|
}
|
||||||
|
issues_data.append(issue_data)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {slug}: {display_title} — "
|
||||||
|
f"{len(articles)} articles, {len(paragraphs)} paragraphs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3: Build intro.json with hierarchical volume groups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Group issues by volume
|
||||||
|
volume_groups = {}
|
||||||
|
for issue in issues_data:
|
||||||
|
vol = issue["volume"]
|
||||||
|
if vol not in volume_groups:
|
||||||
|
volume_groups[vol] = []
|
||||||
|
volume_groups[vol].append(issue)
|
||||||
|
|
||||||
|
# Build chapters array for intro.json
|
||||||
|
chapters = []
|
||||||
|
for vol_num in sorted(volume_groups.keys()):
|
||||||
|
vol_issues = volume_groups[vol_num]
|
||||||
|
sub_chapters = []
|
||||||
|
for issue in vol_issues:
|
||||||
|
sub_chapters.append(
|
||||||
|
{
|
||||||
|
"slug": issue["slug"],
|
||||||
|
"title": issue["title"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
chapters.append(
|
||||||
|
{
|
||||||
|
"title": f"Volume {vol_num}",
|
||||||
|
"subChapters": sub_chapters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
total_issues = len(issues_data)
|
||||||
|
total_articles = sum(len(i["articles"]) for i in issues_data)
|
||||||
|
|
||||||
|
intro_json = {
|
||||||
|
"book": "btg",
|
||||||
|
"title": "Back to Godhead",
|
||||||
|
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||||
|
"year": 1944,
|
||||||
|
"description": f"The original Back to Godhead magazine founded in 1944. {total_issues} issues across {len(volume_groups)} volumes, containing {total_articles} articles on Kṛṣṇa consciousness, Vedic philosophy, and spiritual life.",
|
||||||
|
"totalIssues": total_issues,
|
||||||
|
"totalArticles": total_articles,
|
||||||
|
"chapters": chapters,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 4: Write JSON files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Clean output directory
|
||||||
|
if os.path.exists(issues_dir):
|
||||||
|
for f in os.listdir(issues_dir):
|
||||||
|
os.remove(os.path.join(issues_dir, f))
|
||||||
|
|
||||||
|
# Write intro.json
|
||||||
|
write_json(intro_json, os.path.join(output_dir, "intro.json"))
|
||||||
|
print(f"\n Written: intro.json ({total_issues} issues, {total_articles} articles)")
|
||||||
|
|
||||||
|
# Write issue files
|
||||||
|
order = 100
|
||||||
|
for issue in issues_data:
|
||||||
|
issue_json = {
|
||||||
|
"book": "btg",
|
||||||
|
"volume": issue["volume"],
|
||||||
|
"part": issue["part"],
|
||||||
|
"title": issue["title"],
|
||||||
|
"slug": issue["slug"],
|
||||||
|
"order": order,
|
||||||
|
"articles": issue["articles"],
|
||||||
|
"paragraphs": issue["paragraphs"],
|
||||||
|
}
|
||||||
|
filepath = os.path.join(issues_dir, f"{issue['slug']}.json")
|
||||||
|
write_json(issue_json, filepath)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
print(f" Written: {len(issues_data)} issue files")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 5: Quality checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" QUALITY CHECKS")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
# Check for ScaGoudy remnants
|
||||||
|
sca_chars = set(SCAGOUDY_TO_IAST.keys())
|
||||||
|
sca_remnants = 0
|
||||||
|
for issue in issues_data:
|
||||||
|
for p in issue["paragraphs"]:
|
||||||
|
if any(c in p for c in sca_chars if c not in ("é", "ü", "à")):
|
||||||
|
# Skip common French/German chars that might be legitimate
|
||||||
|
# Focus on clearly ScaGoudy-only chars
|
||||||
|
if "å" in p or "ë" in p or "ö" in p and "Ö" not in p:
|
||||||
|
sca_remnants += 1
|
||||||
|
break
|
||||||
|
if sca_remnants == 0:
|
||||||
|
print(f" ✅ No ScaGoudy remnants detected")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ {sca_remnants} issues may have ScaGoudy remnants")
|
||||||
|
|
||||||
|
# Check for empty issues
|
||||||
|
empty_issues = [i for i in issues_data if len(i["paragraphs"]) == 0]
|
||||||
|
if empty_issues:
|
||||||
|
print(
|
||||||
|
f" ⚠ {len(empty_issues)} empty issues: {[i['slug'] for i in empty_issues]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" ✅ No empty issues")
|
||||||
|
|
||||||
|
# Check centered verses
|
||||||
|
verse_count = 0
|
||||||
|
for issue in issues_data:
|
||||||
|
for p in issue["paragraphs"]:
|
||||||
|
if '<div style="text-align:center">' in p:
|
||||||
|
verse_count += 1
|
||||||
|
print(f" ℹ️ {verse_count} center-aligned verse paragraphs")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" EXTRACTION SUMMARY")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
for vol_num in sorted(volume_groups.keys()):
|
||||||
|
vol_issues = volume_groups[vol_num]
|
||||||
|
vol_articles = sum(len(i["articles"]) for i in vol_issues)
|
||||||
|
vol_paras = sum(len(i["paragraphs"]) for i in vol_issues)
|
||||||
|
print(
|
||||||
|
f" Volume {vol_num}: {len(vol_issues)} issues, {vol_articles} articles, {vol_paras} paragraphs"
|
||||||
|
)
|
||||||
|
for issue in vol_issues:
|
||||||
|
print(
|
||||||
|
f" {issue['slug']}: {issue['title']} "
|
||||||
|
f"({len(issue['articles'])} articles, {len(issue['paragraphs'])} paras)"
|
||||||
|
)
|
||||||
|
print(f"\n Total: {total_issues} issues, {total_articles} articles")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"\nDone! ✨")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract letters from letters.html.
|
||||||
|
|
||||||
|
Each letter starts with a p7 tag containing "Letter to: <recipient>".
|
||||||
|
The header contains recipient, location, and date information.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- p7 = Letter header (recipient, dash, location, date)
|
||||||
|
- p8 = Reference code (YY-MM-DD) and also postscript/closing lines
|
||||||
|
- p9 = Recipient address block
|
||||||
|
- p10 = Salutation ("Dear...")
|
||||||
|
- p11 = Body paragraphs
|
||||||
|
- p12 = Centered text (verses, centered quotes)
|
||||||
|
- p13 = Closing/signature block (color: #408080)
|
||||||
|
- p14 = Indented/numbered items
|
||||||
|
- p16-p22, p28-p37 = Various body text styles
|
||||||
|
|
||||||
|
Output:
|
||||||
|
lib/jsons/letters/letters.json (array of all letters with metadata)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/letters_extract.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ScaGoudy → IAST Unicode mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCAGOUDY_TO_IAST = {
|
||||||
|
"ä": "ā",
|
||||||
|
"Ä": "Ā",
|
||||||
|
"å": "ṛ",
|
||||||
|
"Å": "Ṛ",
|
||||||
|
"ñ": "ṣ",
|
||||||
|
"Ñ": "Ṣ",
|
||||||
|
"ö": "ṭ",
|
||||||
|
"Ö": "Ṭ",
|
||||||
|
"ë": "ṇ",
|
||||||
|
"Ë": "Ṇ",
|
||||||
|
"ò": "ḍ",
|
||||||
|
"Ò": "Ḍ",
|
||||||
|
"ç": "ś",
|
||||||
|
"Ç": "Ś",
|
||||||
|
"ì": "ṅ",
|
||||||
|
"Ì": "Ṅ",
|
||||||
|
"ï": "ñ",
|
||||||
|
"Ï": "Ñ",
|
||||||
|
"ü": "ū",
|
||||||
|
"Ü": "Ū",
|
||||||
|
"é": "ī",
|
||||||
|
"É": "Ī",
|
||||||
|
"à": "ṁ",
|
||||||
|
"ù": "ḥ",
|
||||||
|
"è": "ṝ",
|
||||||
|
"ÿ": "ḷ",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_scagoudy(text: str) -> str:
|
||||||
|
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||||
|
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||||
|
text = text.replace(src, dst)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def get_css_class(tag: Tag) -> str:
|
||||||
|
classes = tag.get("class", [])
|
||||||
|
return classes[0] if classes else ""
|
||||||
|
|
||||||
|
|
||||||
|
def inner_html(tag: Tag) -> str:
|
||||||
|
"""Get inner HTML of a tag, decode ScaGoudy, clean up spans."""
|
||||||
|
html = "".join(str(child) for child in tag.children)
|
||||||
|
html = decode_scagoudy(html)
|
||||||
|
|
||||||
|
# <span class="s1">text</span> → keep inner text (reference style)
|
||||||
|
html = re.sub(
|
||||||
|
r'<span[^>]*class="s\d+"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
# Remove any remaining spans but keep content
|
||||||
|
html = re.sub(r"<span[^>]*>(.*?)</span>", r"\1", html, flags=re.DOTALL)
|
||||||
|
# Remove tab characters
|
||||||
|
html = html.replace("\t", " ")
|
||||||
|
# Collapse multiple spaces
|
||||||
|
html = re.sub(r"[ ]+", " ", html)
|
||||||
|
html = html.strip()
|
||||||
|
# Remove leading <br> tags
|
||||||
|
html = re.sub(r"^(<br\s*/?>[\s]*)+", "", html)
|
||||||
|
# Remove trailing <br> tags
|
||||||
|
html = re.sub(r"(<br\s*/?>[\s]*)+$", "", html)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(data, path: str):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Month name → number mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MONTH_MAP = {
|
||||||
|
"january": 1,
|
||||||
|
"february": 2,
|
||||||
|
"march": 3,
|
||||||
|
"april": 4,
|
||||||
|
"may": 5,
|
||||||
|
"june": 6,
|
||||||
|
"july": 7,
|
||||||
|
"august": 8,
|
||||||
|
"september": 9,
|
||||||
|
"october": 10,
|
||||||
|
"november": 11,
|
||||||
|
"december": 12,
|
||||||
|
"jan": 1,
|
||||||
|
"feb": 2,
|
||||||
|
"mar": 3,
|
||||||
|
"apr": 4,
|
||||||
|
"jun": 6,
|
||||||
|
"jul": 7,
|
||||||
|
"aug": 8,
|
||||||
|
"sep": 9,
|
||||||
|
"sept": 9,
|
||||||
|
"oct": 10,
|
||||||
|
"nov": 11,
|
||||||
|
"dec": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str):
|
||||||
|
"""Parse a date string like '12 July, 1947' or 'May 1949'.
|
||||||
|
|
||||||
|
Returns (year, month, day) where month and day can be None.
|
||||||
|
"""
|
||||||
|
if not date_str:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
date_str = date_str.strip().rstrip(".")
|
||||||
|
|
||||||
|
# Try "DD Month, YYYY" or "DD Month YYYY"
|
||||||
|
m = re.match(r"(\d{1,2})\s+([A-Za-z]+),?\s*(\d{4})", date_str)
|
||||||
|
if m:
|
||||||
|
day = int(m.group(1))
|
||||||
|
month_name = m.group(2).lower()
|
||||||
|
year = int(m.group(3))
|
||||||
|
month = MONTH_MAP.get(month_name)
|
||||||
|
if year >= 1900 and year <= 2000:
|
||||||
|
return year, month, day
|
||||||
|
|
||||||
|
# Try "Month DD, YYYY" or "Month DD YYYY"
|
||||||
|
m = re.match(r"([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{4})", date_str)
|
||||||
|
if m:
|
||||||
|
month_name = m.group(1).lower()
|
||||||
|
day = int(m.group(2))
|
||||||
|
year = int(m.group(3))
|
||||||
|
month = MONTH_MAP.get(month_name)
|
||||||
|
if year >= 1900 and year <= 2000:
|
||||||
|
return year, month, day
|
||||||
|
|
||||||
|
# Try "Month, YYYY" or "Month YYYY"
|
||||||
|
m = re.match(r"([A-Za-z]+),?\s*(\d{4})", date_str)
|
||||||
|
if m:
|
||||||
|
month_name = m.group(1).lower()
|
||||||
|
year = int(m.group(2))
|
||||||
|
month = MONTH_MAP.get(month_name)
|
||||||
|
if month and year >= 1900 and year <= 2000:
|
||||||
|
return year, month, None
|
||||||
|
|
||||||
|
# Try just "YYYY"
|
||||||
|
m = re.match(r"(\d{4})$", date_str.strip())
|
||||||
|
if m:
|
||||||
|
year = int(m.group(1))
|
||||||
|
if year >= 1900 and year <= 2000:
|
||||||
|
return year, None, None
|
||||||
|
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_header(tag: Tag):
|
||||||
|
"""Parse a p7 letter header tag.
|
||||||
|
|
||||||
|
Expected format:
|
||||||
|
Letter to: <recipient>
|
||||||
|
—
|
||||||
|
<location>
|
||||||
|
<date>
|
||||||
|
|
||||||
|
Or sometimes:
|
||||||
|
Letter to: <recipient>
|
||||||
|
<date>
|
||||||
|
|
||||||
|
Returns (recipient, location, date_str).
|
||||||
|
"""
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||||
|
|
||||||
|
recipient = None
|
||||||
|
location = None
|
||||||
|
date_str = None
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return recipient, location, date_str
|
||||||
|
|
||||||
|
# First line: "Letter to: <recipient>"
|
||||||
|
first = lines[0]
|
||||||
|
m = re.match(r"Letter to:\s*(.*)", first, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
recipient = m.group(1).strip().rstrip(",.")
|
||||||
|
recipient = decode_scagoudy(recipient)
|
||||||
|
|
||||||
|
# Remove "—" lines
|
||||||
|
content_lines = [l for l in lines[1:] if l != "—" and l != "-"]
|
||||||
|
|
||||||
|
if len(content_lines) >= 2:
|
||||||
|
# Last line is usually the date
|
||||||
|
potential_date = content_lines[-1]
|
||||||
|
year, month, day = parse_date(potential_date)
|
||||||
|
if year:
|
||||||
|
date_str = potential_date
|
||||||
|
# Everything before the date is location
|
||||||
|
loc_lines = content_lines[:-1]
|
||||||
|
if loc_lines:
|
||||||
|
location = loc_lines[0].strip().rstrip(",.")
|
||||||
|
location = decode_scagoudy(location)
|
||||||
|
else:
|
||||||
|
# Maybe the second-to-last is location and last is something else
|
||||||
|
# Try to find a date anywhere
|
||||||
|
for cl in reversed(content_lines):
|
||||||
|
yr, mo, dy = parse_date(cl)
|
||||||
|
if yr:
|
||||||
|
date_str = cl
|
||||||
|
idx = content_lines.index(cl)
|
||||||
|
if idx > 0:
|
||||||
|
location = content_lines[0].strip().rstrip(",.")
|
||||||
|
location = decode_scagoudy(location)
|
||||||
|
break
|
||||||
|
elif len(content_lines) == 1:
|
||||||
|
# Could be just a date, or just a location
|
||||||
|
potential_date = content_lines[0]
|
||||||
|
year, month, day = parse_date(potential_date)
|
||||||
|
if year:
|
||||||
|
date_str = potential_date
|
||||||
|
else:
|
||||||
|
location = potential_date.strip().rstrip(",.")
|
||||||
|
location = decode_scagoudy(location)
|
||||||
|
|
||||||
|
return recipient, location, date_str
|
||||||
|
|
||||||
|
|
||||||
|
def make_slug(recipient: str, date_ref: str, year, month, day, index: int) -> str:
|
||||||
|
"""Create a unique slug for a letter."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Date part
|
||||||
|
if date_ref and re.match(r"\d{2}-\d{2}", date_ref):
|
||||||
|
parts.append(date_ref)
|
||||||
|
elif year:
|
||||||
|
yr = str(year)[2:] # Last 2 digits
|
||||||
|
if month:
|
||||||
|
if day:
|
||||||
|
parts.append(f"{yr}-{month:02d}-{day:02d}")
|
||||||
|
else:
|
||||||
|
parts.append(f"{yr}-{month:02d}-00")
|
||||||
|
else:
|
||||||
|
parts.append(f"{yr}-00-00")
|
||||||
|
else:
|
||||||
|
parts.append(f"unknown-{index}")
|
||||||
|
|
||||||
|
# Recipient part
|
||||||
|
if recipient:
|
||||||
|
# Slugify recipient name
|
||||||
|
name = recipient.lower()
|
||||||
|
name = re.sub(r"[^a-z0-9]+", "-", name)
|
||||||
|
name = name.strip("-")[:40]
|
||||||
|
parts.append(name)
|
||||||
|
|
||||||
|
slug = "-".join(parts)
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main extraction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
html_path = os.path.join(base_dir, "public", "html", "letters.html")
|
||||||
|
output_dir = os.path.join(base_dir, "lib", "jsons", "letters")
|
||||||
|
|
||||||
|
if not os.path.exists(html_path):
|
||||||
|
print(f"ERROR: {html_path} not found!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Reading {html_path}...")
|
||||||
|
with open(html_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
all_tags = soup.find_all("p")
|
||||||
|
print(f" Parsed {len(all_tags)} <p> tags total")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1: Find all letter boundaries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
letter_starts = []
|
||||||
|
for i, tag in enumerate(all_tags):
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
if cls == "p7" and text.startswith("Letter to:"):
|
||||||
|
letter_starts.append(i)
|
||||||
|
|
||||||
|
print(f"\n Found {len(letter_starts)} letters")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2: Extract each letter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Classes that are just empty spacers
|
||||||
|
SKIP_CLASSES = {"p2", "p15", "p23", "p24", "p26"}
|
||||||
|
|
||||||
|
# p12 = centered (verse-like)
|
||||||
|
CENTERED_CLASSES = {"p12", "p18", "p19", "p20", "p21", "p27"}
|
||||||
|
|
||||||
|
# p13, p34, p38 = signature/closing blocks (colored)
|
||||||
|
SIGNATURE_CLASSES = {"p13", "p34", "p38"}
|
||||||
|
|
||||||
|
letters = []
|
||||||
|
slug_counts = Counter()
|
||||||
|
|
||||||
|
for li, start_idx in enumerate(letter_starts):
|
||||||
|
# Determine end boundary
|
||||||
|
end_idx = (
|
||||||
|
letter_starts[li + 1] if li + 1 < len(letter_starts) else len(all_tags)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse header
|
||||||
|
header_tag = all_tags[start_idx]
|
||||||
|
recipient, location, date_str = parse_header(header_tag)
|
||||||
|
|
||||||
|
# Parse date into components
|
||||||
|
year, month, day = parse_date(date_str) if date_str else (None, None, None)
|
||||||
|
|
||||||
|
# Find date reference code (first p8 after header that matches YY-MM-DD)
|
||||||
|
date_ref = None
|
||||||
|
salutation = None
|
||||||
|
paragraphs = []
|
||||||
|
first_p8_seen = False
|
||||||
|
|
||||||
|
for i in range(start_idx + 1, end_idx):
|
||||||
|
tag = all_tags[i]
|
||||||
|
cls = get_css_class(tag)
|
||||||
|
text = tag.get_text().strip()
|
||||||
|
|
||||||
|
if cls in SKIP_CLASSES:
|
||||||
|
continue
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p8 = reference code or body text
|
||||||
|
if cls == "p8":
|
||||||
|
if not first_p8_seen:
|
||||||
|
# First p8 after header — check if it's a date reference
|
||||||
|
if re.match(r"\d{2}-\d{2}", text):
|
||||||
|
date_ref = text.strip()
|
||||||
|
first_p8_seen = True
|
||||||
|
|
||||||
|
# Try to extract year/month/day from ref if not already parsed
|
||||||
|
if not year and date_ref:
|
||||||
|
ref_parts = date_ref.split("-")
|
||||||
|
if len(ref_parts) >= 3:
|
||||||
|
try:
|
||||||
|
yr = int(ref_parts[0])
|
||||||
|
mo = int(ref_parts[1])
|
||||||
|
dy = int(ref_parts[2])
|
||||||
|
year = 1900 + yr if yr < 100 else yr
|
||||||
|
month = mo if 1 <= mo <= 12 else None
|
||||||
|
day = dy if 1 <= dy <= 31 else None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
first_p8_seen = True
|
||||||
|
# Not a date ref — treat as body text
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Subsequent p8 tags — body/closing text
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p9 = recipient address block
|
||||||
|
if cls == "p9":
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# p10 = salutation
|
||||||
|
if cls == "p10":
|
||||||
|
salutation = decode_scagoudy(text)
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Centered classes (verse blocks)
|
||||||
|
if cls in CENTERED_CLASSES:
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
# Check if it has line breaks (verse-like)
|
||||||
|
if "<br" in html:
|
||||||
|
html = f'<div style="text-align:center">{html}</div>'
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Signature/closing blocks
|
||||||
|
if cls in SIGNATURE_CLASSES:
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html:
|
||||||
|
paragraphs.append(html)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All other body text classes (p11, p14, p16, p22, p28, p29, etc.)
|
||||||
|
html = inner_html(tag)
|
||||||
|
if html and html != "<br>":
|
||||||
|
# Check for verse-like content
|
||||||
|
stripped = html.strip()
|
||||||
|
if (
|
||||||
|
stripped.startswith("<i>")
|
||||||
|
and "<br" in stripped
|
||||||
|
and '<div style="text-align:center">' not in stripped
|
||||||
|
):
|
||||||
|
plain = re.sub(r"</?[bi]>", "", stripped)
|
||||||
|
lines = [
|
||||||
|
l.strip() for l in re.split(r"<br\s*/?>", plain) if l.strip()
|
||||||
|
]
|
||||||
|
if len(lines) >= 2:
|
||||||
|
html = f'<div style="text-align:center">{stripped}</div>'
|
||||||
|
|
||||||
|
paragraphs.append(html)
|
||||||
|
|
||||||
|
# Clean recipient name — strip trailing punctuation
|
||||||
|
if recipient:
|
||||||
|
recipient = recipient.rstrip(":,.; ").strip()
|
||||||
|
# Also clean " :" (space before colon)
|
||||||
|
recipient = re.sub(r"\s+:$", "", recipient).strip()
|
||||||
|
|
||||||
|
# Split multi-recipient strings into individual names
|
||||||
|
# Patterns: "Mukunda, Janaki" or "Mukunda and Janaki"
|
||||||
|
recipient_display = recipient or "Unknown"
|
||||||
|
if recipient:
|
||||||
|
# Split by comma, semicolon, " and ", " & "
|
||||||
|
parts = re.split(r"\s*[,;]\s*|\s+and\s+|\s+&\s+", recipient)
|
||||||
|
recipients_list = []
|
||||||
|
for p in parts:
|
||||||
|
name = p.strip().rstrip(":,.; ").strip()
|
||||||
|
# Clean " :" pattern
|
||||||
|
name = re.sub(r"\s+:$", "", name).strip()
|
||||||
|
if name:
|
||||||
|
recipients_list.append(name)
|
||||||
|
if not recipients_list:
|
||||||
|
recipients_list = ["Unknown"]
|
||||||
|
else:
|
||||||
|
recipients_list = ["Unknown"]
|
||||||
|
|
||||||
|
# Build slug
|
||||||
|
slug = make_slug(recipient_display, date_ref, year, month, day, li)
|
||||||
|
|
||||||
|
# Ensure unique slug
|
||||||
|
if slug in slug_counts:
|
||||||
|
slug_counts[slug] += 1
|
||||||
|
slug = f"{slug}-{slug_counts[slug]}"
|
||||||
|
else:
|
||||||
|
slug_counts[slug] = 1
|
||||||
|
|
||||||
|
# Normalize location
|
||||||
|
if location:
|
||||||
|
# Clean up common variants
|
||||||
|
location = location.rstrip(",.")
|
||||||
|
# Normalize "Vrndavana" variants
|
||||||
|
if location.lower() in ("vrndavana", "vrndävana"):
|
||||||
|
location = "Vrindaban"
|
||||||
|
|
||||||
|
letter = {
|
||||||
|
"slug": slug,
|
||||||
|
"recipients": recipients_list,
|
||||||
|
"recipientDisplay": recipient_display,
|
||||||
|
"date": date_str,
|
||||||
|
"dateRef": date_ref,
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"day": day,
|
||||||
|
"location": location,
|
||||||
|
"salutation": salutation,
|
||||||
|
"paragraphs": paragraphs,
|
||||||
|
}
|
||||||
|
letters.append(letter)
|
||||||
|
|
||||||
|
print(f"\n Extracted {len(letters)} letters")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3: Statistics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Year distribution
|
||||||
|
year_counts = Counter()
|
||||||
|
for letter in letters:
|
||||||
|
if letter["year"]:
|
||||||
|
year_counts[letter["year"]] += 1
|
||||||
|
print(f"\n Year distribution:")
|
||||||
|
for yr in sorted(year_counts.keys()):
|
||||||
|
if 1940 <= yr <= 1980:
|
||||||
|
print(f" {yr}: {year_counts[yr]} letters")
|
||||||
|
|
||||||
|
# Location distribution (top 20)
|
||||||
|
loc_counts = Counter()
|
||||||
|
for letter in letters:
|
||||||
|
if letter["location"]:
|
||||||
|
loc_counts[letter["location"]] += 1
|
||||||
|
print(f"\n Top 20 locations:")
|
||||||
|
for loc, count in loc_counts.most_common(20):
|
||||||
|
print(f" {loc}: {count}")
|
||||||
|
|
||||||
|
# Recipient distribution (by individual name, not group)
|
||||||
|
recip_counts = Counter()
|
||||||
|
for letter in letters:
|
||||||
|
for r in letter["recipients"]:
|
||||||
|
recip_counts[r] += 1
|
||||||
|
print(f"\n Top 20 recipients (individual):")
|
||||||
|
for recip, count in recip_counts.most_common(20):
|
||||||
|
print(f" {recip}: {count}")
|
||||||
|
|
||||||
|
# Letters with missing data
|
||||||
|
no_year = sum(1 for l in letters if not l["year"])
|
||||||
|
no_location = sum(1 for l in letters if not l["location"])
|
||||||
|
no_date = sum(1 for l in letters if not l["date"])
|
||||||
|
no_recipient = sum(1 for l in letters if l["recipients"] == ["Unknown"])
|
||||||
|
print(f"\n Missing data:")
|
||||||
|
print(f" No year: {no_year}")
|
||||||
|
print(f" No location: {no_location}")
|
||||||
|
print(f" No date: {no_date}")
|
||||||
|
print(f" No recipient (Unknown): {no_recipient}")
|
||||||
|
|
||||||
|
# Unique counts
|
||||||
|
all_individual_recipients = set()
|
||||||
|
for l in letters:
|
||||||
|
for r in l["recipients"]:
|
||||||
|
all_individual_recipients.add(r)
|
||||||
|
unique_recipients = len(all_individual_recipients)
|
||||||
|
unique_locations = len(set(l["location"] for l in letters if l["location"]))
|
||||||
|
unique_years = len(set(l["year"] for l in letters if l["year"]))
|
||||||
|
print(f"\n Unique counts:")
|
||||||
|
print(f" Recipients: {unique_recipients}")
|
||||||
|
print(f" Locations: {unique_locations}")
|
||||||
|
print(f" Years: {unique_years}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 4: Write output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Write individual letter files grouped by year for manageable sizes
|
||||||
|
years_with_letters = sorted(
|
||||||
|
set(l["year"] for l in letters if l["year"] and 1940 <= l["year"] <= 1980)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write a summary/index file
|
||||||
|
index_data = {
|
||||||
|
"totalLetters": len(letters),
|
||||||
|
"uniqueRecipients": unique_recipients,
|
||||||
|
"uniqueLocations": unique_locations,
|
||||||
|
"years": [],
|
||||||
|
"topRecipients": [
|
||||||
|
{"name": r, "count": c} for r, c in recip_counts.most_common(50)
|
||||||
|
],
|
||||||
|
"topLocations": [
|
||||||
|
{"name": l, "count": c} for l, c in loc_counts.most_common(50)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for yr in years_with_letters:
|
||||||
|
yr_letters = [l for l in letters if l["year"] == yr]
|
||||||
|
index_data["years"].append(
|
||||||
|
{
|
||||||
|
"year": yr,
|
||||||
|
"count": len(yr_letters),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also count letters without a valid year
|
||||||
|
undated = [
|
||||||
|
l for l in letters if not l["year"] or l["year"] < 1940 or l["year"] > 1980
|
||||||
|
]
|
||||||
|
if undated:
|
||||||
|
index_data["years"].append(
|
||||||
|
{
|
||||||
|
"year": 0,
|
||||||
|
"count": len(undated),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
write_json(index_data, os.path.join(output_dir, "index.json"))
|
||||||
|
print(f"\n Written: index.json")
|
||||||
|
|
||||||
|
# Write letters grouped by year (one JSON file per year)
|
||||||
|
for yr in years_with_letters:
|
||||||
|
yr_letters = [l for l in letters if l["year"] == yr]
|
||||||
|
yr_letters.sort(key=lambda l: (l["month"] or 0, l["day"] or 0))
|
||||||
|
write_json(yr_letters, os.path.join(output_dir, f"{yr}.json"))
|
||||||
|
|
||||||
|
# Write undated letters
|
||||||
|
if undated:
|
||||||
|
write_json(undated, os.path.join(output_dir, "undated.json"))
|
||||||
|
|
||||||
|
print(f" Written: {len(years_with_letters)} year files + undated.json")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 5: Quality checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" QUALITY CHECKS")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
# Check for ScaGoudy remnants
|
||||||
|
sca_chars = {"å", "ë", "ö"}
|
||||||
|
sca_remnants = 0
|
||||||
|
for letter in letters[:100]: # Check first 100
|
||||||
|
for p in letter["paragraphs"]:
|
||||||
|
if any(c in p for c in sca_chars):
|
||||||
|
sca_remnants += 1
|
||||||
|
break
|
||||||
|
if sca_remnants == 0:
|
||||||
|
print(f" ✅ No ScaGoudy remnants detected (checked first 100)")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ {sca_remnants} letters may have ScaGoudy remnants")
|
||||||
|
|
||||||
|
# Check empty letters
|
||||||
|
empty = [l for l in letters if len(l["paragraphs"]) == 0]
|
||||||
|
if empty:
|
||||||
|
print(f" ⚠ {len(empty)} empty letters (no paragraphs)")
|
||||||
|
else:
|
||||||
|
print(f" ✅ No empty letters")
|
||||||
|
|
||||||
|
# Check duplicate slugs
|
||||||
|
slug_list = [l["slug"] for l in letters]
|
||||||
|
dupes = len(slug_list) - len(set(slug_list))
|
||||||
|
if dupes:
|
||||||
|
print(f" ⚠ {dupes} duplicate slugs")
|
||||||
|
else:
|
||||||
|
print(f" ✅ No duplicate slugs")
|
||||||
|
|
||||||
|
# Verse count
|
||||||
|
verse_count = 0
|
||||||
|
for letter in letters:
|
||||||
|
for p in letter["paragraphs"]:
|
||||||
|
if '<div style="text-align:center">' in p:
|
||||||
|
verse_count += 1
|
||||||
|
print(f" ℹ️ {verse_count} center-aligned verse paragraphs")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" EXTRACTION SUMMARY")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f" Total letters: {len(letters)}")
|
||||||
|
print(f" Unique recipients: {unique_recipients}")
|
||||||
|
print(f" Unique locations: {unique_locations}")
|
||||||
|
print(f" Year range: {min(years_with_letters)}–{max(years_with_letters)}")
|
||||||
|
print(f" Total paragraphs: {sum(len(l['paragraphs']) for l in letters)}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"\nDone! ✨")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* seed-letters.ts — Push letters into MongoDB.
|
||||||
|
*
|
||||||
|
* Collection written:
|
||||||
|
* - letters → 6,866 individual letter documents
|
||||||
|
*
|
||||||
|
* JSON sources:
|
||||||
|
* - lib/jsons/letters/1947.json through 1977.json
|
||||||
|
* - lib/jsons/letters/undated.json
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/seed-letters.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
|
||||||
|
dotenv.config({ path: ".env.local" });
|
||||||
|
dotenv.config({ path: ".env" });
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.error("❌ DATABASE_URL is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_DIR = path.resolve(__dirname, "..");
|
||||||
|
const LETTERS_DIR = path.join(BASE_DIR, "lib", "jsons", "letters");
|
||||||
|
|
||||||
|
function readJson<T>(filePath: string): T {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeString(s: string): string {
|
||||||
|
s = s.normalize("NFC");
|
||||||
|
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
||||||
|
s = s.replace(/[\x7f-\x9f]/g, "");
|
||||||
|
s = s.replace(/[\uD800-\uDFFF]/g, "");
|
||||||
|
s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, "");
|
||||||
|
s = s.replace(/[\uFDD0-\uFDEF\uFFFE\uFFFF]/g, "");
|
||||||
|
s = Buffer.from(s, "utf-8").toString("utf-8");
|
||||||
|
s = s.split("\0").join("");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDoc(obj: unknown): unknown {
|
||||||
|
if (typeof obj === "string") return sanitizeString(obj);
|
||||||
|
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
|
||||||
|
if (obj !== null && typeof obj === "object") {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>))
|
||||||
|
result[key] = sanitizeDoc(value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LetterJson = {
|
||||||
|
slug: string;
|
||||||
|
recipients: string[];
|
||||||
|
recipientDisplay: string;
|
||||||
|
date: string | null;
|
||||||
|
dateRef: string | null;
|
||||||
|
year: number | null;
|
||||||
|
month: number | null;
|
||||||
|
day: number | null;
|
||||||
|
location: string | null;
|
||||||
|
salutation: string | null;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🌱 Seeding Letters...\n");
|
||||||
|
|
||||||
|
const client = new MongoClient(DATABASE_URL!);
|
||||||
|
await client.connect();
|
||||||
|
console.log("📡 Connected to MongoDB\n");
|
||||||
|
|
||||||
|
const db = client.db();
|
||||||
|
const col = db.collection("letters");
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Delete all existing letters
|
||||||
|
const deleted = await col.deleteMany({});
|
||||||
|
console.log(` 🗑️ Deleted ${deleted.deletedCount} existing letters`);
|
||||||
|
|
||||||
|
// Ensure indexes
|
||||||
|
await col.createIndex({ slug: 1 }, { unique: true });
|
||||||
|
await col.createIndex({ year: 1 });
|
||||||
|
await col.createIndex({ recipient: 1 });
|
||||||
|
await col.createIndex({ location: 1 });
|
||||||
|
await col.createIndex({ year: 1, month: 1 });
|
||||||
|
|
||||||
|
// Read all year files
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(LETTERS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".json") && f !== "index.json")
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`\n 📂 Found ${files.length} letter files`);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const letters = readJson<LetterJson[]>(path.join(LETTERS_DIR, file));
|
||||||
|
console.log(`\n 📄 ${file}: ${letters.length} letters`);
|
||||||
|
|
||||||
|
for (const letter of letters) {
|
||||||
|
const doc = sanitizeDoc(letter) as Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await col.insertOne(doc);
|
||||||
|
inserted++;
|
||||||
|
|
||||||
|
if (inserted % 500 === 0) {
|
||||||
|
console.log(` ... ${inserted} letters inserted`);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (msg.includes("duplicate key")) {
|
||||||
|
// Skip duplicates silently
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
` ❌ Error inserting ${letter.slug}: ${msg.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
if (errors > 10) {
|
||||||
|
console.error(" Too many errors, stopping.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors > 10) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
const totalDocs = await col.countDocuments();
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
console.log("\n" + "─".repeat(50));
|
||||||
|
console.log(`📊 Summary:`);
|
||||||
|
console.log(` Inserted: ${inserted} letters`);
|
||||||
|
console.log(` Errors: ${errors}`);
|
||||||
|
console.log(` Total in collection: ${totalDocs}`);
|
||||||
|
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("❌ Seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* seed-magazines.ts — Push Magazines into MongoDB.
|
||||||
|
*
|
||||||
|
* Collections written:
|
||||||
|
* - magazine_meta → one doc per magazine
|
||||||
|
* - magazine_issues → N issue docs per magazine
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/seed-magazines.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
|
||||||
|
dotenv.config({ path: ".env.local" });
|
||||||
|
dotenv.config({ path: ".env" });
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.error("❌ DATABASE_URL is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_DIR = path.resolve(__dirname, "..");
|
||||||
|
const MAGAZINES_DIR = path.join(BASE_DIR, "lib", "jsons", "magazines");
|
||||||
|
|
||||||
|
function readJson<T>(filePath: string): T {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeString(s: string): string {
|
||||||
|
// Normalize to NFC
|
||||||
|
s = s.normalize("NFC");
|
||||||
|
// Remove null bytes and C0 control chars (keep \t \n \r)
|
||||||
|
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
||||||
|
// Remove C1 control chars
|
||||||
|
s = s.replace(/[\x7f-\x9f]/g, "");
|
||||||
|
// Remove lone surrogates (U+D800–U+DFFF) that are invalid in BSON
|
||||||
|
s = s.replace(/[\uD800-\uDFFF]/g, "");
|
||||||
|
// Remove zero-width characters that can confuse BSON length calculations
|
||||||
|
s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, "");
|
||||||
|
// Remove non-characters (U+FDD0–U+FDEF, U+FFFE, U+FFFF)
|
||||||
|
s = s.replace(/[\uFDD0-\uFDEF\uFFFE\uFFFF]/g, "");
|
||||||
|
// Remove any remaining non-BMP characters that could cause issues
|
||||||
|
// (keep common supplementary planes like emoji, but remove PUA etc.)
|
||||||
|
s = s.replace(/[\uDB80-\uDBFF][\uDC00-\uDFFF]/g, ""); // Supplementary PUA
|
||||||
|
// Round-trip through Buffer to guarantee valid UTF-8 byte sequence
|
||||||
|
s = Buffer.from(s, "utf-8").toString("utf-8");
|
||||||
|
// Final safety: ensure the string is null-terminated compatible
|
||||||
|
// by removing any embedded nulls that survived
|
||||||
|
s = s.split("\0").join("");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDoc(obj: unknown): unknown {
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
return sanitizeString(obj);
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
|
||||||
|
if (obj !== null && typeof obj === "object") {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>))
|
||||||
|
result[key] = sanitizeDoc(value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedOneMagazine(
|
||||||
|
db: ReturnType<MongoClient["db"]>,
|
||||||
|
magDir: string,
|
||||||
|
): Promise<{ bookCode: string; issues: number }> {
|
||||||
|
const introPath = path.join(magDir, "intro.json");
|
||||||
|
if (!fs.existsSync(introPath)) throw new Error(`No intro.json in ${magDir}`);
|
||||||
|
const intro = sanitizeDoc(
|
||||||
|
readJson<Record<string, unknown>>(introPath),
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const bookCode = intro.book as string;
|
||||||
|
|
||||||
|
const metaCol = db.collection("magazine_meta");
|
||||||
|
await metaCol.replaceOne(
|
||||||
|
{ book: bookCode },
|
||||||
|
{ book: bookCode, data: intro },
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
console.log(` 📖 Upserted magazine_meta for "${intro.title}" (${bookCode})`);
|
||||||
|
|
||||||
|
const issuesCol = db.collection("magazine_issues");
|
||||||
|
// Delete old issues for this magazine
|
||||||
|
await issuesCol.deleteMany({ book: bookCode });
|
||||||
|
|
||||||
|
const issuesDir = path.join(magDir, "issues");
|
||||||
|
let inserted = 0;
|
||||||
|
if (fs.existsSync(issuesDir)) {
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(issuesDir)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
|
.sort();
|
||||||
|
for (const file of files) {
|
||||||
|
const issue = sanitizeDoc(
|
||||||
|
readJson<Record<string, unknown>>(path.join(issuesDir, file)),
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Deep-sanitize each paragraph string
|
||||||
|
const rawParas = (issue.paragraphs as string[]) || [];
|
||||||
|
const cleanParas = rawParas.map((p: string) => sanitizeString(String(p)));
|
||||||
|
|
||||||
|
// Store paragraphs as a single JSON string to avoid MongoDB BSON
|
||||||
|
// wire protocol issues with large string arrays.
|
||||||
|
// The data layer will JSON.parse() this back into an array on read.
|
||||||
|
const paragraphsJson = JSON.stringify(cleanParas);
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
book: bookCode,
|
||||||
|
volume: issue.volume,
|
||||||
|
part: issue.part,
|
||||||
|
title: issue.title,
|
||||||
|
slug: issue.slug,
|
||||||
|
order: issue.order,
|
||||||
|
paragraphsJson,
|
||||||
|
paragraphs: [] as string[], // Keep empty array for schema compat
|
||||||
|
};
|
||||||
|
|
||||||
|
await issuesCol.replaceOne({ book: bookCode, slug: issue.slug }, doc, {
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
console.log(` 📄 Issues: ${inserted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await issuesCol.countDocuments({ book: bookCode });
|
||||||
|
console.log(` ✅ ${bookCode}: ${total} issue entries`);
|
||||||
|
return { bookCode, issues: inserted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🌱 Seeding Magazines...\n");
|
||||||
|
const client = new MongoClient(DATABASE_URL!);
|
||||||
|
await client.connect();
|
||||||
|
console.log("📡 Connected to MongoDB\n");
|
||||||
|
const db = client.db();
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (!fs.existsSync(MAGAZINES_DIR)) {
|
||||||
|
console.error(`❌ ${MAGAZINES_DIR} not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdirs = fs
|
||||||
|
.readdirSync(MAGAZINES_DIR)
|
||||||
|
.filter((e) => {
|
||||||
|
const p = path.join(MAGAZINES_DIR, e);
|
||||||
|
return (
|
||||||
|
fs.statSync(p).isDirectory() &&
|
||||||
|
fs.existsSync(path.join(p, "intro.json"))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (subdirs.length === 0) {
|
||||||
|
console.error("❌ No magazine directories found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`📂 Found ${subdirs.length} magazine(s)\n`);
|
||||||
|
|
||||||
|
let totalIssues = 0;
|
||||||
|
const bookCodes: string[] = [];
|
||||||
|
for (const subdir of subdirs) {
|
||||||
|
console.log(`\n📝 Processing: ${subdir}`);
|
||||||
|
const result = await seedOneMagazine(db, path.join(MAGAZINES_DIR, subdir));
|
||||||
|
bookCodes.push(result.bookCode);
|
||||||
|
totalIssues += result.issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
console.log("\n" + "─".repeat(50));
|
||||||
|
console.log(`📊 Summary: ${subdirs.length} magazines, ${totalIssues} issues`);
|
||||||
|
console.log(` Book codes: ${bookCodes.join(", ")}`);
|
||||||
|
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("❌ Seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user