From 47a2dc375ef757446e91235799e698be6ad4696c Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 2 May 2026 17:45:32 +0530 Subject: [PATCH] fix: SVA chapters --- app/books/sva/[chapter]/page.tsx | 167 +++--- app/books/sva/page.tsx | 53 +- lib/sva.ts | 418 +++++++------ .../__pycache__/sva_extract.cpython-313.pyc | Bin 0 -> 42471 bytes scripts/seed-sva.ts | 181 ++++-- scripts/sva_extract.py | 555 ++++++++++++------ 6 files changed, 869 insertions(+), 505 deletions(-) create mode 100644 scripts/__pycache__/sva_extract.cpython-313.pyc diff --git a/app/books/sva/[chapter]/page.tsx b/app/books/sva/[chapter]/page.tsx index e252da2e..3f07f49b 100644 --- a/app/books/sva/[chapter]/page.tsx +++ b/app/books/sva/[chapter]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { getSvaContent, getSvaSidebarItems, + getNavigableItems, type SvaChapter, type SvaSidebarItem, } from "@/lib/sva"; @@ -28,18 +29,24 @@ export default async function SvaChapterPage({ if (!content) notFound(); const sidebarItems = await getSvaSidebarItems(); - const currentIdx = sidebarItems.findIndex((item) => item.slug === slug); - const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null; + // Build a flat list of navigable items (skip "parent" type — they're labels, not pages) + const navigableItems = getNavigableItems(sidebarItems); + const currentIdx = navigableItems.findIndex((item) => item.slug === slug); + + const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null; const next = - currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null; + currentIdx < navigableItems.length - 1 + ? navigableItems[currentIdx + 1] + : null; - // Build the page title - const isChapter = - "chapter" in content && - typeof (content as SvaChapter).chapter === "number" && - (content as SvaChapter).chapter !== null; - const chapterNum = isChapter ? (content as SvaChapter).chapter : null; + // Determine if this is a sub-chapter (has parentChapter) + const isSubChapter = + "parentChapter" in content && + typeof (content as SvaChapter).parentChapter === "number"; + const parentChapterNum = isSubChapter + ? (content as SvaChapter).parentChapter + : null; return (
@@ -51,8 +58,8 @@ export default async function SvaChapterPage({ ← Songs of the Vaiṣṇava Ācāryas

- {chapterNum != null && ( - Part {chapterNum}: + {parentChapterNum != null && ( + Part {parentChapterNum}: )} {content.title}

@@ -111,33 +118,21 @@ function SvaSidebar({ items: SvaSidebarItem[]; currentSlug: string; }) { - const frontMatterItems: SvaSidebarItem[] = []; - const chapterItems: SvaSidebarItem[] = []; - const backMatterItems: SvaSidebarItem[] = []; - - let pastChapters = false; - - for (const item of items) { - if (item.type === "chapter") { - pastChapters = true; - chapterItems.push(item); - } else if (!pastChapters) { - frontMatterItems.push(item); - } else { - backMatterItems.push(item); - } - } + // Split items by section + const frontMatter = items.filter((i) => i.type === "intro-segment"); + const parents = items.filter((i) => i.type === "parent"); + const subChapters = items.filter((i) => i.type === "sub-chapter"); return ( ); } @@ -189,15 +193,6 @@ function SidebarLink({ item: SvaSidebarItem; isActive: boolean; }) { - let label: string; - if (item.type === "chapter" && item.chapter != null) { - // Extract just the title portion after "Part N: " - const titlePart = item.label.replace(/^Part \d+:\s*/, ""); - label = `${item.chapter}. ${titlePart}`; - } else { - label = item.label; - } - return ( - {label} + {item.label} ); } @@ -231,26 +226,11 @@ function SvaNav({ }) { const mobileNavGroups: MobileNavGroup[] = []; - const frontMatterItems: SvaSidebarItem[] = []; - const chapterItems: SvaSidebarItem[] = []; - const backMatterItems: SvaSidebarItem[] = []; - let pastChapters = false; - - for (const item of sidebarItems) { - if (item.type === "chapter") { - pastChapters = true; - chapterItems.push(item); - } else if (!pastChapters) { - frontMatterItems.push(item); - } else { - backMatterItems.push(item); - } - } - - if (frontMatterItems.length > 0) { + const frontMatter = sidebarItems.filter((i) => i.type === "intro-segment"); + if (frontMatter.length > 0) { mobileNavGroups.push({ title: "Front Matter", - items: frontMatterItems.map((i) => ({ + items: frontMatter.map((i) => ({ slug: i.slug, label: i.label, href: `/books/sva/${i.slug}`, @@ -259,26 +239,19 @@ function SvaNav({ }); } + const chapterItems = sidebarItems.filter( + (i) => i.type === "parent" || i.type === "sub-chapter", + ); if (chapterItems.length > 0) { mobileNavGroups.push({ - title: "Parts", + title: "Songs", items: chapterItems.map((i) => ({ slug: i.slug, label: i.label, href: `/books/sva/${i.slug}`, type: i.type, - })), - }); - } - - if (backMatterItems.length > 0) { - mobileNavGroups.push({ - title: "Back Matter", - items: backMatterItems.map((i) => ({ - slug: i.slug, - label: i.label, - href: `/books/sva/${i.slug}`, - type: i.type, + parentChapter: i.parentChapter, + isParent: i.type === "parent", })), }); } @@ -301,9 +274,7 @@ function SvaNav({ > - {prev.type === "chapter" && prev.chapter != null - ? `Part ${prev.chapter}` - : prev.label} + {prev.label} Previous @@ -321,9 +292,7 @@ function SvaNav({ )} > - {next.type === "chapter" && next.chapter != null - ? `Part ${next.chapter}` - : next.label} + {next.label} Next diff --git a/app/books/sva/page.tsx b/app/books/sva/page.tsx index fad2c070..cb4bcc2a 100644 --- a/app/books/sva/page.tsx +++ b/app/books/sva/page.tsx @@ -12,7 +12,7 @@ export default async function SvaPage() { const introSegments = await getSvaIntroSegments(); - // Split intro segments into front matter (order < 99) and back matter (order >= 99) + // Split intro segments into front matter (before chapters) and back matter (after) const frontMatter = introSegments.filter((seg) => seg.order < 99); const backMatter = introSegments.filter((seg) => seg.order >= 99); @@ -69,35 +69,32 @@ export default async function SvaPage() {
)} - {/* Chapters (Parts) */} -
-

- Parts -

- - -
    - {meta.chapters.map((ch) => ( -
  • - -
    - - Part {ch.number} - + {/* Parts — hierarchical parent → song (sub-chapter) structure */} + {meta.chapters.map((ch) => ( +
    +

    + Part {ch.number}: {ch.title} +

    + + +
      + {ch.subChapters.map((sub) => ( +
    • +

      - {ch.title} + {sub.title}

      -
    - -
  • - ))} -
-
-
-
+ + + ))} + + + + + ))} {/* Back Matter */} {backMatter.length > 0 && ( diff --git a/lib/sva.ts b/lib/sva.ts index df1ef201..d582a306 100644 --- a/lib/sva.ts +++ b/lib/sva.ts @@ -1,9 +1,10 @@ /** * Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA). * - * Reads from MongoDB via Prisma, same as the rest of the app. - * SVA is a prose/song book with 4 parts (chapters) plus intro segments. - * We use volume=0 as the sentinel for "no volumes". + * Reads from MongoDB via Prisma. + * SVA is a song book with 4 parent parts, each containing multiple songs + * (sub-chapters). Each song is stored as its own prose_chapters document + * with slugs like "1-1", "1-2", "2-1", etc. * * When the DB isn't seeded yet, falls back to reading local JSON files * from lib/jsons/books/sva/. @@ -22,11 +23,11 @@ import path from "path"; // Constants // --------------------------------------------------------------------------- +const BOOK = "sva"; + /** Volume sentinel for books without volumes. */ const NO_VOLUME = 0; -const BOOK = "sva"; - const SVA_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "sva"); // --------------------------------------------------------------------------- @@ -37,15 +38,21 @@ export type SvaMeta = { book: string; title: string; author: string; - year?: number; + year: number; description: string; totalChapters: number; - chapters: SvaChapterEntry[]; + chapters: SvaParentChapter[]; }; -export type SvaChapterEntry = { +export type SvaParentChapter = { number: number; title: string; + subChapters: SvaSubChapterEntry[]; +}; + +export type SvaSubChapterEntry = { + slug: string; // "1-1", "1-2", etc. + title: string; }; export type SvaIntroSegment = { @@ -59,27 +66,29 @@ export type SvaIntroSegment = { export type SvaChapter = { book: string; - chapter: number | null; + chapter: string; // "1-1", "2-1", etc. + parentChapter: number; + subChapter: number; title: string; + parentTitle: string; type: string; - slug?: string; paragraphs: string[]; }; /** Union type used in sidebar / table-of-contents navigation. */ export type SvaSidebarItem = { - /** URL-safe slug: "intro-front-pages", "intro-foreword", "1", "4" */ + /** URL-safe slug: "intro-front-pages", "1-1", "parent-1", etc. */ slug: string; /** Human-readable label for the sidebar */ label: string; - /** "intro-segment" | "chapter" */ + /** "intro-segment" | "parent" | "sub-chapter" */ type: string; - /** Chapter number, or null for non-chapter items */ - chapter: number | null; + /** Parent chapter number, or null for intro segments */ + parentChapter: number | null; }; // --------------------------------------------------------------------------- -// HTML sanitisation (mirrors lib/api.ts) +// HTML sanitisation // --------------------------------------------------------------------------- function sanitizeHtml(html: string): string { @@ -148,13 +157,29 @@ function getLocalIntroSegment(rawSlug: string): SvaIntroSegment | null { return readLocalJson(filePath); } -function getLocalChapter(chapterNum: number): SvaChapter | null { - const filePath = path.join( - SVA_JSON_DIR, - "chapters", - String(chapterNum).padStart(2, "0") + ".json", - ); - return readLocalJson(filePath); +function getLocalSubChapter(slug: string): SvaChapter | null { + const filePath = path.join(SVA_JSON_DIR, "chapters", `${slug}.json`); + const raw = readLocalJson<{ + book: string; + slug: string; + parentChapter: number; + subChapter: number; + title: string; + parentTitle: string; + type: string; + paragraphs: string[]; + }>(filePath); + if (!raw) return null; + return { + book: raw.book, + chapter: raw.slug, + parentChapter: raw.parentChapter, + subChapter: raw.subChapter, + title: raw.title, + parentTitle: raw.parentTitle, + type: raw.type, + paragraphs: raw.paragraphs, + }; } // --------------------------------------------------------------------------- @@ -162,11 +187,15 @@ function getLocalChapter(chapterNum: number): SvaChapter | null { // --------------------------------------------------------------------------- /** - * Book-level metadata (title, author, chapter list, etc.) + * Book-level metadata (title, author, chapter list with sub-chapters, etc.) */ export async function getSvaMeta(): Promise { - const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); - if (meta) return meta.data as unknown as SvaMeta; + try { + const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); + if (meta) return meta.data as unknown as SvaMeta; + } catch { + // Prisma BSON decode error — fall through to local JSON + } // Fallback to local JSON const introPath = path.join(SVA_JSON_DIR, "intro.json"); @@ -181,20 +210,24 @@ export async function getSvaMeta(): Promise { * All intro segments, ordered by their `order` field. */ export async function getSvaIntroSegments(): Promise { - const results = await prisma.proseChapter.findMany({ - where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" }, - orderBy: { order: "asc" }, - }); + try { + const results = await prisma.proseChapter.findMany({ + where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" }, + orderBy: { order: "asc" }, + }); - if (results.length > 0) { - return results.map((doc) => ({ - slug: doc.slug, - title: doc.title, - type: doc.type, - book: doc.book, - order: doc.order, - paragraphs: normalizeParagraphs(doc.paragraphs), - })); + if (results.length > 0) { + return results.map((doc) => ({ + slug: doc.slug, + title: doc.title, + type: doc.type, + book: doc.book, + order: doc.order, + paragraphs: normalizeParagraphs(doc.paragraphs), + })); + } + } catch { + // Prisma BSON decode error — fall through to local JSON } // Fallback to local JSON @@ -203,14 +236,16 @@ export async function getSvaIntroSegments(): Promise { /** * A single intro segment by slug. - * Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug). + * Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug). */ export async function getSvaIntroSegment( slug: string, ): Promise { // DB stores with "intro-" prefix; accept either form const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`; - const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug; + const rawSlug = slug.startsWith("intro-") + ? slug.replace(/^intro-/, "") + : slug; const doc = await prisma.proseChapter.findUnique({ where: { @@ -240,42 +275,54 @@ export async function getSvaIntroSegment( } // --------------------------------------------------------------------------- -// Chapters +// Chapters (sub-chapters like "1-1", "2-3", etc.) // --------------------------------------------------------------------------- /** - * A numbered chapter (1–4). + * A sub-chapter (song) by its slug (e.g. "1-1", "2-3"). */ -export async function getSvaChapter( - chapterNum: number, -): Promise { - if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) { - return null; - } - - const doc = await prisma.proseChapter.findUnique({ - where: { - book_volume_slug: { - book: BOOK, - volume: NO_VOLUME, - slug: String(chapterNum), +export async function getSvaChapter(slug: string): Promise { + try { + const doc = await prisma.proseChapter.findUnique({ + where: { + book_volume_slug: { + book: BOOK, + volume: NO_VOLUME, + slug, + }, }, - }, - }); + }); - if (doc) { - return { - book: doc.book, - chapter: doc.chapter, - title: doc.title, - type: doc.type, - slug: doc.slug, - paragraphs: normalizeParagraphs(doc.paragraphs), - }; + if (doc) { + // Parse parentChapter and subChapter from slug "X-Y" + const parts = slug.split("-"); + const parentChapter = parseInt(parts[0], 10); + const subChapter = parseInt(parts[1], 10); + + // Derive parentTitle from meta if available + const meta = await getSvaMeta(); + const parentEntry = meta?.chapters.find( + (ch) => ch.number === parentChapter, + ); + const parentTitle = parentEntry?.title ?? ""; + + return { + book: doc.book, + chapter: slug, + parentChapter, + subChapter, + title: doc.title, + parentTitle, + type: doc.type, + paragraphs: normalizeParagraphs(doc.paragraphs), + }; + } + } catch { + // Prisma BSON decode error — fall through to local JSON } // Fallback to local JSON - const local = getLocalChapter(chapterNum); + const local = getLocalSubChapter(slug); if (!local) return null; return { ...local, @@ -290,10 +337,10 @@ export async function getSvaChapter( /** * Get content by URL slug. * - * "intro-front-pages" → intro segment - * "intro-foreword" → intro segment - * "intro-introduction" → intro segment - * "1" … "4" → chapter (part) + * "intro-front-pages" → intro segment + * "intro-foreword" → intro segment + * "intro-introduction" → intro segment + * "1-1", "2-3", etc. → sub-chapter (song) */ export async function getSvaContent( slug: string, @@ -302,12 +349,8 @@ export async function getSvaContent( return getSvaIntroSegment(slug); } - const num = Number(slug); - if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) { - return getSvaChapter(num); - } - - return null; + // Sub-chapter slug like "1-1", "2-3" + return getSvaChapter(slug); } // --------------------------------------------------------------------------- @@ -315,131 +358,172 @@ export async function getSvaContent( // --------------------------------------------------------------------------- /** - * Build the full sidebar navigation list. + * Build the full sidebar navigation list with hierarchical structure. * - * Order: front-pages → foreword → introduction → parts 1–4 + * Order: front-pages → foreword → introduction + * → parent-1 (label) → 1-1, 1-2, ... → parent-2 (label) → 2-1, 2-2, ... + * + * "parent" type items are non-clickable headings. Only "intro-segment" and + * "sub-chapter" items are navigable pages. */ export async function getSvaSidebarItems(): Promise { - const [meta, allDocs] = await Promise.all([ - getSvaMeta(), - prisma.proseChapter.findMany({ + let allDocs: { + slug: string; + title: string; + type: string; + chapter: number | null; + order: number; + }[] = []; + + const meta = await getSvaMeta(); + + try { + allDocs = await prisma.proseChapter.findMany({ where: { book: BOOK, volume: NO_VOLUME }, orderBy: { order: "asc" }, - select: { slug: true, title: true, type: true, chapter: true, order: true }, - }), - ]); - - // If we got DB results, build from those - if (meta && allDocs.length > 0) { - return buildSidebarFromDocs(meta, allDocs); + select: { + slug: true, + title: true, + type: true, + chapter: true, + order: true, + }, + }); + } catch { + // Prisma BSON decode error — build sidebar from meta alone } - // Fallback: build from local JSON - return buildSidebarFromLocal(meta); -} + if (!meta) { + // Try building from local JSON + return buildSidebarFromLocal(); + } -function buildSidebarFromDocs( - meta: SvaMeta, - allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[], -): SvaSidebarItem[] { const items: SvaSidebarItem[] = []; // Intro segments that come BEFORE chapters (order < 99) - for (const doc of allDocs) { - if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) { + if (allDocs.length > 0) { + for (const doc of allDocs) { + if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) { + items.push({ + slug: doc.slug, + label: doc.title, + type: "intro-segment", + parentChapter: null, + }); + } + } + } else { + // Fallback: local intro segments + const localIntros = getLocalIntroSegments(); + for (const seg of localIntros) { + if ((seg.order ?? 0) < 99) { + items.push({ + slug: `intro-${seg.slug}`, + label: seg.title, + type: "intro-segment", + parentChapter: null, + }); + } + } + } + + // Chapters — grouped by parent chapter with sub-chapters + for (const ch of meta.chapters) { + // Parent heading (non-clickable label) + items.push({ + slug: `parent-${ch.number}`, + label: `Part ${ch.number}: ${ch.title}`, + type: "parent", + parentChapter: ch.number, + }); + + // Sub-chapters (songs) under this parent + for (const sub of ch.subChapters) { items.push({ - slug: doc.slug, - label: doc.title, - type: "intro-segment", - chapter: null, + slug: sub.slug, + label: sub.title, + type: "sub-chapter", + parentChapter: ch.number, }); } } - // Chapters 1–4 (use meta.chapters for consistent title/ordering) - for (const ch of meta.chapters) { - items.push({ - slug: String(ch.number), - label: `Part ${ch.number}: ${ch.title}`, - type: "chapter", - chapter: ch.number, - }); - } - // Intro segments that come AFTER chapters (order >= 99) - for (const doc of allDocs) { - if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) { - items.push({ - slug: doc.slug, - label: doc.title, - type: "intro-segment", - chapter: null, - }); + if (allDocs.length > 0) { + for (const doc of allDocs) { + if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) { + items.push({ + slug: doc.slug, + label: doc.title, + type: "intro-segment", + parentChapter: null, + }); + } } } return items; } -function buildSidebarFromLocal(meta: SvaMeta | null): SvaSidebarItem[] { +function buildSidebarFromLocal(): SvaSidebarItem[] { const items: SvaSidebarItem[] = []; - // Local intro segments (front matter) + // Intro segments const introSegs = getLocalIntroSegments(); - const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99); - const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99); - - for (const seg of frontMatter) { - items.push({ - slug: `intro-${seg.slug}`, - label: seg.title, - type: "intro-segment", - chapter: null, - }); - } - - // Chapters from meta or generate default 1-4 - if (meta && meta.chapters) { - for (const ch of meta.chapters) { + for (const seg of introSegs) { + if ((seg.order ?? 0) < 99) { items.push({ - slug: String(ch.number), - label: `Part ${ch.number}: ${ch.title}`, - type: "chapter", - chapter: ch.number, + slug: `intro-${seg.slug}`, + label: seg.title, + type: "intro-segment", + parentChapter: null, }); } - } else { - // Fallback: scan local chapter JSONs - const chaptersDir = path.join(SVA_JSON_DIR, "chapters"); - if (fs.existsSync(chaptersDir)) { - const files = fs - .readdirSync(chaptersDir) - .filter((f) => f.endsWith(".json")) - .sort(); - for (const file of files) { - const ch = readLocalJson(path.join(chaptersDir, file)); - if (ch) { - const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10); - items.push({ - slug: String(num), - label: `Part ${num}: ${ch.title}`, - type: "chapter", - chapter: num, - }); - } + } + + // Load meta for chapter structure + const introPath = path.join(SVA_JSON_DIR, "intro.json"); + const meta = readLocalJson(introPath); + + if (meta?.chapters) { + for (const ch of meta.chapters) { + items.push({ + slug: `parent-${ch.number}`, + label: `Part ${ch.number}: ${ch.title}`, + type: "parent", + parentChapter: ch.number, + }); + + for (const sub of ch.subChapters) { + items.push({ + slug: sub.slug, + label: sub.title, + type: "sub-chapter", + parentChapter: ch.number, + }); } } } // Back matter - for (const seg of backMatter) { - items.push({ - slug: `intro-${seg.slug}`, - label: seg.title, - type: "intro-segment", - chapter: null, - }); + for (const seg of introSegs) { + if ((seg.order ?? 0) >= 99) { + items.push({ + slug: `intro-${seg.slug}`, + label: seg.title, + type: "intro-segment", + parentChapter: null, + }); + } } return items; } + +/** + * Get the flat list of navigable sidebar items (excludes "parent" headings). + * Used for prev/next navigation. + */ +export function getNavigableItems(items: SvaSidebarItem[]): SvaSidebarItem[] { + return items.filter((item) => item.type !== "parent"); +} diff --git a/scripts/__pycache__/sva_extract.cpython-313.pyc b/scripts/__pycache__/sva_extract.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e4ca5ac1f1a69a2058ecfba591a2590b3f57f1ee GIT binary patch literal 42471 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 literal 0 HcmV?d00001 diff --git a/scripts/seed-sva.ts b/scripts/seed-sva.ts index 85b8fd48..512650e0 100644 --- a/scripts/seed-sva.ts +++ b/scripts/seed-sva.ts @@ -1,15 +1,19 @@ /** * seed-sva.ts — Push Songs of the Vaiṣṇava Ācāryas JSON data into MongoDB. * - * Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid + * Uses the native MongoDB driver (same pattern as seed-ssr.ts) to avoid * Prisma replica-set write requirements. * - * SVA is a prose/song book with 4 parts (chapters) plus intro segments. - * We use volume=0 as the sentinel for "no volumes". + * SVA is a song book with 4 parent parts, each containing multiple songs + * (sub-chapters). Each song is stored as its own prose_chapters document. + * + * Sub-chapters are stored with slugs like "1-1", "1-2", "2-1", etc. + * Each sub-chapter JSON has parentChapter and subChapter fields used to + * compute a stable ordering: parentChapter * 100 + subChapter. * * Collections written: - * - book_meta → one doc { book: "sva", data: } - * - prose_chapters → intro segments + 4 chapters + * - book_meta → one doc { book: "sva", data: } + * - prose_chapters → 3 intro segments + N sub-chapters (songs) * * Usage: * npx tsx scripts/seed-sva.ts @@ -44,6 +48,40 @@ function readJson(filePath: string): T { return JSON.parse(fs.readFileSync(filePath, "utf-8")); } +/** + * Recursively sanitize all strings in a document to prevent BSON encoding errors. + * + * Strategy: NFC-normalize every string, strip control characters and lone + * surrogates, then round-trip through Buffer to guarantee the bytes are + * well-formed UTF-8 that the MongoDB C driver can handle. + */ +function sanitizeDoc(obj: unknown): unknown { + if (typeof obj === "string") { + // 1. NFC normalize (composes combining characters) + let s = obj.normalize("NFC"); + // 2. Remove null bytes and C0 control chars (keep \t \n \r) + s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ""); + // 3. Remove lone surrogates (U+D800–U+DFFF) that are invalid in BSON + s = s.replace(/[\uD800-\uDFFF]/g, ""); + // 4. Remove zero-width characters that can confuse BSON length calculations + s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, ""); + // 5. Round-trip through Buffer to guarantee valid UTF-8 byte sequence + s = Buffer.from(s, "utf-8").toString("utf-8"); + return s; + } + if (Array.isArray(obj)) { + return obj.map(sanitizeDoc); + } + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + result[key] = sanitizeDoc(value); + } + return result; + } + return obj; +} + /** * Insert documents one at a time with a small delay every N docs. * Uses insertOne to avoid BSON wire-protocol corruption. @@ -53,7 +91,36 @@ async function insertBatched( docs: Record[], ) { for (let i = 0; i < docs.length; i++) { - await col.insertOne(docs[i]); + const sanitized = sanitizeDoc(docs[i]) as Record; + + // Work around BSON driver bug with large string arrays: + // Store paragraphs as a single JSON-encoded string instead of a native + // array. The data layer (lib/sva.ts) already handles stringified JSON + // via normalizeParagraphs(). + if (Array.isArray(sanitized.paragraphs)) { + // Stringify, then sanitize the resulting string to ensure clean UTF-8 + let jsonStr = JSON.stringify(sanitized.paragraphs); + // NFC normalize the whole JSON string + jsonStr = jsonStr.normalize("NFC"); + // Remove any stray control chars or surrogates + jsonStr = jsonStr.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ""); + jsonStr = jsonStr.replace(/[\uD800-\uDFFF]/g, ""); + jsonStr = jsonStr.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, ""); + // Round-trip through Buffer to guarantee valid UTF-8 byte sequence + jsonStr = Buffer.from(jsonStr, "utf-8").toString("utf-8"); + sanitized.paragraphs = jsonStr; + } + + try { + await col.insertOne(sanitized); + } catch (err) { + const slug = (sanitized.slug as string) || "unknown"; + const title = (sanitized.title as string) || "unknown"; + console.error( + `\n ❌ Failed inserting doc ${i} (slug="${slug}", title="${title}")`, + ); + throw err; + } if ((i + 1) % 100 === 0) { await new Promise((r) => setTimeout(r, 100)); } @@ -71,7 +138,11 @@ type SvaIntroJson = { year: number; description: string; totalChapters: number; - chapters: { number: number; title: string }[]; + chapters: { + number: number; + title: string; + subChapters: { slug: string; title: string }[]; + }[]; }; type SvaIntroSegmentJson = { @@ -83,10 +154,13 @@ type SvaIntroSegmentJson = { paragraphs: string[]; }; -type SvaChapterJson = { +type SvaSubChapterJson = { book: string; - chapter: number | null; + slug: string; + parentChapter: number; + subChapter: number; title: string; + parentTitle: string; type: string; paragraphs: string[]; }; @@ -104,65 +178,88 @@ async function seedSva(db: ReturnType) { const intro = readJson(introPath); - // ---- Book meta ---- + // ---- 1. Book meta ---- const metaCol = db.collection("book_meta"); await metaCol.deleteMany({ book: "sva" }); await metaCol.insertOne({ book: "sva", data: intro }); console.log(" 📖 SVA book meta inserted"); - // ---- Prose chapters collection ---- + // ---- 2. Prose chapters collection ---- const proseCol = db.collection("prose_chapters"); await proseCol.deleteMany({ book: "sva" }); const docs: Record[] = []; // Intro segments (front-pages, foreword, introduction) + const introSegmentFiles = [ + "front-pages.json", + "foreword.json", + "introduction.json", + ]; + const introDir = path.join(SVA_DIR, "intro"); - if (fs.existsSync(introDir)) { - const introFiles = fs - .readdirSync(introDir) - .filter((f) => f.endsWith(".json")) - .sort(); - - for (const file of introFiles) { - const seg = readJson(path.join(introDir, file)); - docs.push({ - book: "sva", - volume: NO_VOLUME, - chapter: null, - title: seg.title, - type: seg.type || "intro-segment", - slug: `intro-${seg.slug || file.replace(".json", "")}`, - order: seg.order ?? 0, - paragraphs: seg.paragraphs, - }); + for (const file of introSegmentFiles) { + const filePath = path.join(introDir, file); + if (!fs.existsSync(filePath)) { + console.warn(` ⚠️ Intro segment not found, skipping: ${file}`); + continue; } - console.log(` 📄 Intro segments: ${introFiles.length}`); - } - // Chapters (01.json – 04.json) + const seg = readJson(filePath); + + docs.push({ + book: "sva", + volume: NO_VOLUME, + chapter: null, + title: seg.title, + type: "intro-segment", + slug: `intro-${seg.slug}`, + order: seg.order, + paragraphs: seg.paragraphs, + }); + } + console.log(` 📄 Intro segments: ${docs.length}`); + + // Sub-chapters (songs): 1-1.json, 1-2.json, 2-1.json, etc. const chaptersDir = path.join(SVA_DIR, "chapters"); + let songCount = 0; if (fs.existsSync(chaptersDir)) { const chapterFiles = fs .readdirSync(chaptersDir) .filter((f) => f.endsWith(".json")) - .sort(); + .sort((a, b) => { + // Sort by parentChapter then subChapter numerically + const parseSlug = (name: string) => { + const slug = name.replace(".json", ""); + const parts = slug.split("-"); + return { + parent: parseInt(parts[0], 10), + sub: parseInt(parts[1], 10), + }; + }; + const aa = parseSlug(a); + const bb = parseSlug(b); + if (aa.parent !== bb.parent) return aa.parent - bb.parent; + return aa.sub - bb.sub; + }); for (const file of chapterFiles) { - const ch = readJson(path.join(chaptersDir, file)); - const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10); + const sc = readJson(path.join(chaptersDir, file)); + const order = sc.parentChapter * 100 + sc.subChapter; + docs.push({ book: "sva", volume: NO_VOLUME, - chapter: chNum, - title: ch.title, - type: ch.type || "chapter", - slug: String(chNum), - order: 100 + chNum, - paragraphs: ch.paragraphs, + chapter: null, // null since slug is a string like "1-1" + title: sc.title, + type: "sub-chapter", + slug: sc.slug, + order, + paragraphs: sc.paragraphs, }); + songCount++; } - console.log(` 📖 Chapters: ${chapterFiles.length}`); + console.log(` 🎵 Songs (sub-chapters): ${songCount}`); } // Insert all docs diff --git a/scripts/sva_extract.py b/scripts/sva_extract.py index 7e381cd1..8a0496b6 100644 --- a/scripts/sva_extract.py +++ b/scripts/sva_extract.py @@ -1,13 +1,21 @@ #!/usr/bin/env python3 """Extract Songs of the Vaiṣṇava Ācāryas (1972) from other.html. -The book has 3 intro segments (front-pages, foreword, introduction), -and 4 chapters (Parts 1–4). +Hierarchical sub-chapter extraction: each song within a part becomes its OWN +chapter JSON file with a parent-child relationship. + +Parent chapters (Parts 1–4) are containers only — no content of their own. + +Numbering: Part 1, Song 1 → slug "1-1", Part 2, Song 3 → slug "2-3", etc. + +Song boundaries are delimited by p49 bookmarks with text like: + "SVA 1: Śrī Guru Praṇāma" + "SVA 2: Aruṇodaya-kīrtana" Output: - lib/jsons/books/sva/intro.json — Book metadata - lib/jsons/books/sva/intro/*.json — Intro segments - lib/jsons/books/sva/chapters/*.json — Chapters (01–04) + lib/jsons/books/sva/intro.json — Book metadata with hierarchical chapters + lib/jsons/books/sva/intro/*.json — Intro segments (front-pages, foreword, introduction) + lib/jsons/books/sva/chapters/*.json — Sub-chapter files (1-1.json, 1-2.json, 2-1.json, ...) Usage: python3 scripts/sva_extract.py @@ -17,6 +25,7 @@ import json import os import re import sys +from collections import defaultdict from bs4 import BeautifulSoup, NavigableString, Tag @@ -39,13 +48,17 @@ SCAGOUDY_TO_IAST = { "ç": "ś", "Ç": "Ś", "ì": "ṅ", + "Ì": "Ṅ", "ï": "ñ", + "Ï": "Ñ", "ü": "ū", "Ü": "Ū", "é": "ī", "É": "Ī", "à": "ṁ", + "À": "Ṁ", "ù": "ḥ", + "Ù": "Ḥ", "è": "ṝ", "û": "ṁl", "ÿ": "ḷ", @@ -69,6 +82,18 @@ def decode_html_content(tag: Tag) -> str: ) # Clean up Apple-tab-span leftovers (tab characters) inner = inner.replace("\t", " ") + # Force valid UTF-8 by round-tripping through encode/decode with replacement + inner = inner.encode("utf-8", errors="replace").decode("utf-8", errors="replace") + # Strip null bytes and other control characters that break BSON + inner = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", inner) + # Remove lone surrogates (U+D800–U+DFFF) that are invalid in BSON/MongoDB + inner = re.sub(r"[\ud800-\udfff]", "", inner) + # Merge adjacent bold tags: → (nothing) + for _ in range(5): + inner = re.sub(r"\s*", "", inner) + # Remove empty bold and italic tags + inner = re.sub(r"\s*", "", inner) + inner = re.sub(r"\s*", "", inner) # Normalize whitespace inner = re.sub(r"\s+", " ", inner).strip() return inner @@ -138,10 +163,16 @@ SONG_TITLE_CLASS = "p27" SONG_TITLE_VARIANT_CLASS = "p169" # Verse line classes (italic, centered) -VERSE_PADA_CLASSES = frozenset(["p5", "p53", "p24", "p66", "p255"]) +VERSE_PADA_CLASSES = frozenset(["p5", "p53", "p24", "p66", "p255", "p134"]) # Verse number classes (e.g. "3)", "4)", "[1]") — centered but NOT verse text -VERSE_NUMBER_CLASS = "p248" +VERSE_NUMBER_CLASSES = frozenset(["p248", "p30"]) + +# Translation variant classes (bold, centered) — in addition to p56 +TRANSLATION_VARIANT_CLASSES = frozenset(["p86", "p240", "p242"]) + +# Reference/purport note classes that should be centered +CENTERED_NOTE_CLASSES = frozenset(["p241", "p22"]) # Synonyms text SYNONYMS_CLASS = "p54" @@ -246,6 +277,18 @@ SKIP_CLASSES = frozenset( ) +# --------------------------------------------------------------------------- +# Clean parent titles (pre-decoded IAST) +# --------------------------------------------------------------------------- + +CLEAN_PARENT_TITLES = { + 1: "Prayers and Praṇāmas", + 2: "Songs of Śrīla Bhaktivinoda Ṭhākura", + 3: "Songs of Śrīla Narottama dāsa Ṭhākura", + 4: "Songs by other Ācāryas", +} + + # --------------------------------------------------------------------------- # Extraction helpers # --------------------------------------------------------------------------- @@ -337,12 +380,12 @@ def process_table_t3(table: Tag) -> list: joined = "
".join(verse_lines) result.append(f'
{joined}
') - # Right cell: English translation + # Right cell: English translation — centered right_ps = tds[1].find_all("p") for p in right_ps: html = decode_html_content(p) if html and html.strip() and html.strip() != "\xa0": - result.append(html) + result.append(f'
{html}
') return result @@ -367,7 +410,7 @@ def extract_paragraphs( start_idx: int, end_idx: int, ) -> list: - """Extract paragraphs for a section (chapter, intro segment). + """Extract paragraphs for a section (song, intro segment). Handles all CSS classes within the SVA content. Handles and
elements. @@ -460,6 +503,19 @@ def extract_paragraphs( # Non-verse paragraph — flush any accumulated verse first flush_verse() + # Short p6 content (verse numbers like "1)", "2)") should be centered + # Long p6 content (body paragraphs) stays left-aligned + if cls == "p6": + html = decode_html_content(elem) + if html: + plain = re.sub(r"<[^>]+>", "", html).strip() + # Short content that looks like a verse number or bracketed ref + if len(plain) <= 10 and re.match(r"^[\[\(]?\d+[\]\)]?\.?$", plain): + result.append(f'
{html}
') + else: + result.append(html) + continue + # Front pages text (p18) if cls == FRONT_PAGES_CLASS: html = decode_html_content(elem) @@ -467,29 +523,29 @@ def extract_paragraphs( result.append(html) continue - # Section heading (p31) — bold + # Section heading (p31) — bold, centered if cls == SECTION_HEADING_CLASS: html = decode_html_content(elem) if html: if not html.strip().startswith(""): html = f"{html}" - result.append(html) + result.append(f'
{html}
') continue - # Song title (p27) — bold + # Song title (p27) — bold, centered if cls == SONG_TITLE_CLASS: html = decode_html_content(elem) if html: if not html.strip().startswith(""): html = f"{html}" - result.append(html) + result.append(f'
{html}
') continue - # Song title variant (p169) — bold italic with author + # Song title variant (p169) — bold italic with author, centered if cls == SONG_TITLE_VARIANT_CLASS: html = decode_html_content(elem) if html: - result.append(html) + result.append(f'
{html}
') continue # Synonyms (p54) @@ -499,8 +555,25 @@ def extract_paragraphs( result.append(html) continue - # Verse number heading (p248) — e.g. "3)", "4)", "[1]" — center it - if cls == VERSE_NUMBER_CLASS: + # Verse number heading (p248, p30) — e.g. "3)", "4)", "[1]" — center it + if cls in VERSE_NUMBER_CLASSES: + flush_verse() + html = decode_html_content(elem) + if html: + result.append(f'
{html}
') + continue + + # Translation variant classes (p86, p240, p242) — bold, centered + if cls in TRANSLATION_VARIANT_CLASSES: + flush_verse() + html = decode_html_content(elem) + if html: + result.append(f'
{html}
') + continue + + # Centered note classes (p241) + if cls in CENTERED_NOTE_CLASSES: + flush_verse() html = decode_html_content(elem) if html: result.append(f'
{html}
') @@ -513,18 +586,18 @@ def extract_paragraphs( result.append(f'
{html}
') continue - # SYNONYMS header (p165) + # SYNONYMS header (p165) — centered if cls == SYNONYMS_HEADER_CLASS: html = decode_html_content(elem) if html: - result.append(html) + result.append(f'
{html}
') continue - # TRANSLATION header (p55) + # TRANSLATION header (p55) — centered if cls == TRANSLATION_HEADER_CLASS: html = decode_html_content(elem) if html: - result.append(html) + result.append(f'
{html}
') continue # Mantra text (p19) @@ -578,11 +651,11 @@ def extract_paragraphs( result.append(html) continue - # Reference notes + # Reference notes (p187, p231, p233, p237, p32, p251) — centered if cls in REFERENCE_CLASSES: html = decode_html_content(elem) if html: - result.append(html) + result.append(f'
{html}
') continue # Bold text classes @@ -592,11 +665,11 @@ def extract_paragraphs( result.append(html) continue - # Synonym notes (p247) + # Synonym note (p247) — centered if cls == SYNONYM_NOTES_CLASS: html = decode_html_content(elem) if html: - result.append(html) + result.append(f'
{html}
') continue # Bold headings (p224, p223) @@ -686,8 +759,8 @@ def extract_sva(soup: BeautifulSoup) -> dict: Returns a dict with: "intro_segments": list of intro segment dicts - "chapters": list of chapter dicts - "chapter_listing": list of {number, title} for intro.json + "sub_chapters": list of sub-chapter dicts (one per song) + "parent_chapters": list of parent chapter info for intro.json """ # Get all top-level

and

elements all_elements = list(soup.find_all(["p", "table"])) @@ -700,18 +773,22 @@ def extract_sva(soup: BeautifulSoup) -> dict: # Step 2: Find all SVA bookmarks bookmarks = find_sva_bookmarks(all_elements, sva_start, sva_end) print(f" Found {len(bookmarks)} SVA bookmarks") - for pos, text, cls in bookmarks[:20]: + for pos, text, cls in bookmarks[:30]: print(f" Elem ~{pos} [{cls}]: {text[:80]}") - if len(bookmarks) > 20: - print(f" ... and {len(bookmarks) - 20} more") + if len(bookmarks) > 30: + print(f" ... and {len(bookmarks) - 30} more") - # Step 3: Categorize bookmarks into sections + # Step 3: Categorize bookmarks # p13 bookmarks define major sections: # "SVA: Foreword" → foreword # "SVA: Introduction" → introduction # "SVA 2: ..." → Part 2 start # "SVA 3: ..." → Part 3 start # "SVA 4: ..." → Part 4 start + # p49 bookmarks define songs within parts: + # "SVA 1: ..." → songs in Part 1 + # "SVA 2: ..." → songs in Part 2 + # etc. foreword_pos = None introduction_pos = None @@ -719,18 +796,8 @@ def extract_sva(soup: BeautifulSoup) -> dict: part3_pos = None part4_pos = None - # p49 bookmarks define songs within parts: - # "SVA 1: ..." → songs in Part 1 - # "SVA 2: ..." → songs in Part 2 - # "SVA 3: ..." → songs in Part 3 - # "SVA 4: ..." → songs in Part 4 - - # Also find the first SVA 1: bookmark (start of Part 1) first_part1_pos = None - # Find "SVA: A Glimpse..." bookmark in intro - glimpse_pos = None - for pos, text, cls in bookmarks: if cls == BOOKMARK_CLASS: # p13 if text.strip() == "SVA: Foreword": @@ -747,8 +814,6 @@ def extract_sva(soup: BeautifulSoup) -> dict: if cls == SONG_BOOKMARK_CLASS: # p49 if text.strip().startswith("SVA 1:") and first_part1_pos is None: first_part1_pos = pos - if "Glimpse" in text or "Life of" in text: - glimpse_pos = pos print(f"\n Section boundaries:") print(f" Book start: elem {sva_start}") @@ -821,63 +886,133 @@ def extract_sva(soup: BeautifulSoup) -> dict: "paragraphs": intro_paras, } - # Step 5: Extract chapters (Parts 1–4) - chapter_boundaries = [] - - if first_part1_pos is not None: - ch1_end = part2_pos if part2_pos else sva_end - chapter_boundaries.append((1, "Prayers and Praṇāmas", first_part1_pos, ch1_end)) - - if part2_pos is not None: - ch2_end = part3_pos if part3_pos else sva_end - chapter_boundaries.append( - (2, "Songs of Śrīla Bhaktivinoda Ṭhākura", part2_pos, ch2_end) - ) - - if part3_pos is not None: - ch3_end = part4_pos if part4_pos else sva_end - chapter_boundaries.append( - (3, "Songs of Śrīla Narottama dāsa Ṭhākura", part3_pos, ch3_end) - ) - - if part4_pos is not None: - chapter_boundaries.append((4, "Songs by other Ācāryas", part4_pos, sva_end)) - - chapters = [] - for ch_num, ch_title, ch_start, ch_end in chapter_boundaries: - # Start extraction from the element after the p13 bookmark (for parts 2-4) - # or from the p49 bookmark itself for part 1 - content_start = ch_start - if ch_num >= 2: - # For parts 2-4, skip the p13 bookmark and the p31 heading that follows - content_start = ch_start + 1 - - paragraphs = extract_paragraphs(all_elements, content_start, ch_end) - - ch = { - "book": "sva", - "chapter": ch_num, - "title": ch_title, - "type": "chapter", - "paragraphs": paragraphs, - } - chapters.append(ch) - - # Step 6: Build results intro_segments = [front_pages] if foreword: intro_segments.append(foreword) if introduction: intro_segments.append(introduction) - chapter_listing = [ - {"number": ch["chapter"], "title": ch["title"]} for ch in chapters - ] + # Step 5: Collect all p49 song bookmarks grouped by part number + song_bookmarks_by_part = defaultdict(list) + + for pos, text, cls in bookmarks: + if cls != SONG_BOOKMARK_CLASS: + continue + # Parse: "SVA 1: Śrī Guru Praṇāma" → part_num=1, title="Śrī Guru Praṇāma" + m = re.match(r"SVA\s+(\d+):\s+(.+)", text.strip()) + if m: + part_num = int(m.group(1)) + song_title = m.group(2).strip() + song_bookmarks_by_part[part_num].append( + { + "pos": pos, + "title": song_title, + "raw_text": text.strip(), + } + ) + + # Step 6: Define part boundaries + part_boundaries = [] + + if first_part1_pos is not None: + ch1_end = part2_pos if part2_pos else sva_end + part_boundaries.append((1, first_part1_pos, ch1_end)) + + if part2_pos is not None: + ch2_end = part3_pos if part3_pos else sva_end + part_boundaries.append((2, part2_pos, ch2_end)) + + if part3_pos is not None: + ch3_end = part4_pos if part4_pos else sva_end + part_boundaries.append((3, part3_pos, ch3_end)) + + if part4_pos is not None: + part_boundaries.append((4, part4_pos, sva_end)) + + # Step 7: Extract sub-chapters (songs) for each part + sub_chapters = [] + parent_chapters = [] + + for part_num, part_start, part_end in part_boundaries: + parent_title = CLEAN_PARENT_TITLES.get(part_num, f"Part {part_num}") + + songs = song_bookmarks_by_part.get(part_num, []) + # Sort by position + songs.sort(key=lambda s: s["pos"]) + + sub_entries = [] + song_counter = 0 + + for si, song in enumerate(songs): + song_title = decode_scagoudy(song["title"]) + + # Detect "Purport to..." bookmarks — merge into preceding song + is_purport = song_title.lower().startswith("purport to") + if ( + is_purport + and sub_chapters + and sub_chapters[-1]["parentChapter"] == part_num + ): + # Merge this purport's content into the last song + content_start = song["pos"] + 1 + if si + 1 < len(songs): + content_end = songs[si + 1]["pos"] + else: + content_end = part_end + + purport_paragraphs = extract_paragraphs( + all_elements, content_start, content_end + ) + sub_chapters[-1]["paragraphs"].extend(purport_paragraphs) + continue + + song_counter += 1 + slug = f"{part_num}-{song_counter}" + + # Content range: from this bookmark to next song bookmark (or part end) + # We start AFTER the bookmark element itself + content_start = song["pos"] + 1 + if si + 1 < len(songs): + content_end = songs[si + 1]["pos"] + else: + content_end = part_end + + paragraphs = extract_paragraphs(all_elements, content_start, content_end) + + sub_chapters.append( + { + "book": "sva", + "slug": slug, + "parentChapter": part_num, + "subChapter": song_counter, + "title": song_title, + "parentTitle": parent_title, + "type": "sub-chapter", + "paragraphs": paragraphs, + } + ) + sub_entries.append({"slug": slug, "title": song_title}) + + parent_chapters.append( + { + "number": part_num, + "title": parent_title, + "subChapters": sub_entries, + } + ) + + print(f"\n Part {part_num}: {parent_title} → {len(sub_entries)} songs") + for entry in sub_entries: + matching = [sc for sc in sub_chapters if sc["slug"] == entry["slug"]] + para_count = len(matching[0]["paragraphs"]) if matching else 0 + print( + f" {entry['slug']:>5}: {para_count:>3} paragraphs — {entry['title']}" + ) return { "intro_segments": intro_segments, - "chapters": chapters, - "chapter_listing": chapter_listing, + "sub_chapters": sub_chapters, + "parent_chapters": parent_chapters, } @@ -910,8 +1045,8 @@ def main(): result = extract_sva(soup) intro_segments = result["intro_segments"] - chapters = result["chapters"] - chapter_listing = result["chapter_listing"] + sub_chapters = result["sub_chapters"] + parent_chapters = result["parent_chapters"] # --- Write intro.json --- intro_meta = { @@ -921,7 +1056,7 @@ def main(): "year": 1972, "description": "Bengali devotional songs by the great Vaiṣṇava teachers, with English translations and commentary.", "totalChapters": 4, - "chapters": chapter_listing, + "chapters": parent_chapters, } intro_path = os.path.join(output_base, "intro.json") write_json(intro_meta, intro_path) @@ -933,75 +1068,82 @@ def main(): filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json") write_json(seg, filepath) print( - f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}" + f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}" ) - # --- Write chapters --- - print(f"\n--- Chapters ---") - for ch in chapters: - ch_num_str = str(ch["chapter"]).zfill(2) - filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json") - write_json(ch, filepath) + # --- Write sub-chapter files (songs) --- + print(f"\n--- Songs (Sub-Chapters) ---") + for sc in sub_chapters: + slug = sc["slug"] + filepath = os.path.join(output_base, "chapters", f"{slug}.json") + write_json(sc, filepath) print( - f" Part {ch['chapter']}: {len(ch['paragraphs']):>3} paragraphs — " - f"{ch['title']}" + f" {slug:>5}: {len(sc['paragraphs']):>3} paragraphs — " + f"[Part {sc['parentChapter']}] {sc['title']}" ) # --- Summary --- total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments) - total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters) + total_chapter_paras = sum(len(sc["paragraphs"]) for sc in sub_chapters) total_paras = total_intro_paras + total_chapter_paras print(f"\n{'=' * 60}") print(f" EXTRACTION SUMMARY") print(f"{'=' * 60}") print(f" Intro segments: {len(intro_segments)}") - for seg in intro_segments: - print( - f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs" - ) - print(f" Chapters (Parts): {len(chapters)}") - for ch in chapters: - print( - f" Part {ch['chapter']}: {len(ch['paragraphs'])} paragraphs — {ch['title']}" - ) + print(f" Parent chapters: {len(parent_chapters)}") + print(f" Songs (sub-ch): {len(sub_chapters)}") print(f" Total paragraphs: {total_paras}") print(f" Intro: {total_intro_paras}") - print(f" Chapters: {total_chapter_paras}") - if chapters: - print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}") + print(f" Songs: {total_chapter_paras}") + if sub_chapters: + print(f" Avg paras/song: {total_chapter_paras / len(sub_chapters):.1f}") print(f" Output directory: {output_base}/") + # --- Per-parent breakdown --- + print(f"\n--- Part Breakdown ---") + for pc in parent_chapters: + part_num = pc["number"] + sub_count = len(pc["subChapters"]) + part_paras = sum( + len(sc["paragraphs"]) + for sc in sub_chapters + if sc["parentChapter"] == part_num + ) + print( + f" Part {part_num}: {sub_count:>2} songs, {part_paras:>4} paragraphs — {pc['title']}" + ) + # --- Samples --- - print(f"\n--- Sample: Part 1 (Prayers and Praṇāmas) ---") - if chapters: - ch1 = chapters[0] - for i, p in enumerate(ch1["paragraphs"][:5]): - preview = re.sub(r"<[^>]+>", "", p)[:120] + print(f"\n--- Sample: Front Pages ---") + if intro_segments: + front = intro_segments[0] + for i, p in enumerate(front["paragraphs"][:5]): + preview = re.sub(r"<[^>]+>", "", p)[:100] print(f" [{i}] {preview}...") - if len(chapters) >= 2: - print(f"\n--- Sample: Part 2 (Songs of Bhaktivinoda Ṭhākura) ---") - ch2 = chapters[1] - for i, p in enumerate(ch2["paragraphs"][:5]): - preview = re.sub(r"<[^>]+>", "", p)[:120] + if sub_chapters: + first_sc = sub_chapters[0] + print(f"\n--- Sample: {first_sc['slug']} ({first_sc['title']}) ---") + for i, p in enumerate(first_sc["paragraphs"][:5]): + preview = re.sub(r"<[^>]+>", "", p)[:100] print(f" [{i}] {preview}...") - if len(chapters) >= 4: - print(f"\n--- Sample: Part 4 (Songs by other Ācāryas) ---") - ch4 = chapters[3] - for i, p in enumerate(ch4["paragraphs"][:5]): - preview = re.sub(r"<[^>]+>", "", p)[:120] + if len(sub_chapters) > 1: + last_sc = sub_chapters[-1] + print(f"\n--- Sample: {last_sc['slug']} ({last_sc['title']}) ---") + for i, p in enumerate(last_sc["paragraphs"][:5]): + preview = re.sub(r"<[^>]+>", "", p)[:100] print(f" [{i}] {preview}...") # --- Quality Checks --- print(f"\n--- Quality Checks ---") # Check all 4 parts present - expected_chapters = set(range(1, 5)) - actual_chapters = set(ch["chapter"] for ch in chapters) - missing = expected_chapters - actual_chapters - extra = actual_chapters - expected_chapters + expected_parents = set(range(1, 5)) + actual_parents = {pc["number"] for pc in parent_chapters} + missing = expected_parents - actual_parents + extra = actual_parents - expected_parents if missing: print(f" ⚠ Missing parts: {sorted(missing)}") if extra: @@ -1009,7 +1151,7 @@ def main(): if not missing and not extra: print(f" ✅ All 4 parts present") - # Check intro segments + # Check all 3 intro segments present expected_intros = {"front-pages", "foreword", "introduction"} actual_intros = {s["slug"] for s in intro_segments} missing_intros = expected_intros - actual_intros @@ -1018,17 +1160,24 @@ def main(): else: print(f" ✅ All 3 intro segments present") - # Check empty chapters - empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0] - if empty_chapters: - print(f" ⚠ Empty parts (0 paragraphs): {empty_chapters}") + # Check empty sub-chapters + empty_scs = [sc["slug"] for sc in sub_chapters if len(sc["paragraphs"]) == 0] + if empty_scs: + print(f" ⚠ Empty songs (0 paragraphs): {empty_scs}") else: - print(f" ✅ No empty parts") + print(f" ✅ No empty songs") + + # Check empty intro segments + empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0] + if empty_intros: + print(f" ⚠ Empty intro segments: {empty_intros}") + else: + print(f" ✅ No empty intro segments") # Check ScaGoudy decoding encoded_chars = set("äåöëòçüéàùè") suspect_count = 0 - all_sections = chapters + intro_segments + all_sections = sub_chapters + intro_segments for section in all_sections: for p in section["paragraphs"]: plain = re.sub(r"<[^>]+>", "", p) @@ -1045,16 +1194,16 @@ def main(): # Check verse blocks verse_block_count = 0 - for ch in chapters: - for p in ch["paragraphs"]: + for sc in sub_chapters: + for p in sc["paragraphs"]: if '
' in p: verse_block_count += 1 - print(f" ℹ {verse_block_count} verse blocks in chapters") + print(f" ℹ {verse_block_count} verse blocks in songs") # Check table-derived content table_verse_count = 0 - for ch in chapters: - for p in ch["paragraphs"]: + for sc in sub_chapters: + for p in sc["paragraphs"]: if '
' in p and "
" in p: table_verse_count += 1 print(f" ℹ {table_verse_count} multi-line verse blocks (likely from tables)") @@ -1077,11 +1226,11 @@ def main(): # Sample verse blocks print(f"\n--- Sample Verse Blocks ---") shown = 0 - for ch in chapters: - for p in ch["paragraphs"]: + for sc in sub_chapters: + for p in sc["paragraphs"]: if '
' in p: preview = re.sub(r"<[^>]+>", "", p)[:120] - print(f" Part {ch['chapter']}: {preview}...") + print(f" {sc['slug']} ({sc['title']}): {preview}...") shown += 1 if shown >= 5: break @@ -1089,24 +1238,92 @@ def main(): break # Song title check - print(f"\n--- Song Titles in Chapters ---") - song_count = 0 - sample_songs = [] - for ch in chapters: - ch_songs = 0 - for p in ch["paragraphs"]: - # Bold italic song titles - if "" in p and "" in p: - plain = re.sub(r"<[^>]+>", "", p).strip() - if len(plain) < 100: - ch_songs += 1 - if len(sample_songs) < 8: - sample_songs.append(f" Part {ch['chapter']}: {plain}") - song_count += ch_songs - print(f" Part {ch['chapter']}: ~{ch_songs} song titles") - print(f" Total song titles: ~{song_count}") - for s in sample_songs: - print(s) + print(f"\n--- Song Titles ---") + for pc in parent_chapters: + print(f" Part {pc['number']}: {pc['title']}") + for sub in pc["subChapters"]: + print(f" {sub['slug']:>5}: {sub['title']}") + + # Verify slug numbering continuity within each parent + print(f"\n--- Slug Numbering Check ---") + for pc in parent_chapters: + part_num = pc["number"] + slugs = [s["slug"] for s in pc["subChapters"]] + expected_slugs = [f"{part_num}-{i + 1}" for i in range(len(slugs))] + if slugs == expected_slugs: + print( + f" ✅ Part {part_num}: {len(slugs)} songs, slugs {slugs[0]}..{slugs[-1]}" + ) + else: + print( + f" ⚠ Part {part_num}: slug mismatch! expected {expected_slugs}, got {slugs}" + ) + + # Sub-chapter size check + print(f"\n--- Song Size Check ---") + very_small = [] + very_large = [] + for sc in sub_chapters: + count = len(sc["paragraphs"]) + if count < 1: + very_small.append((sc["slug"], count, sc["title"])) + elif count > 200: + very_large.append((sc["slug"], count, sc["title"])) + if very_small: + print(f" ⚠ Very small songs (0 paragraphs):") + for slug, count, title in very_small: + print(f" {slug}: {count} paragraphs — {title}") + else: + print(f" ✅ All songs have ≥ 1 paragraph") + if very_large: + print(f" ⚠ Very large songs (> 200 paragraphs):") + for slug, count, title in very_large: + print(f" {slug}: {count} paragraphs — {title}") + else: + print(f" ✅ All songs have ≤ 200 paragraphs") + + # Sub-chapter title decoding check + print(f"\n--- Song Title Decoding Check ---") + bad_titles = 0 + for sc in sub_chapters: + title = sc["title"] + if "å" in title or "ë" in title or "ñ" in title or "ä" in title: + print(f" ⚠ {sc['slug']} title has un-decoded ScaGoudy: {title}") + bad_titles += 1 + if bad_titles == 0: + print(f" ✅ All {len(sub_chapters)} song titles properly decoded") + + # Check for residual span tags + span_count = 0 + for sc in sub_chapters: + for p in sc["paragraphs"]: + if 'class="' in p and "]+>", "", p) + if is_dash_line(plain): + dash_count += 1 + for seg in intro_segments: + for p in seg["paragraphs"]: + plain = re.sub(r"<[^>]+>", "", p) + if is_dash_line(plain): + dash_count += 1 + if dash_count == 0: + print(f" ✅ No dashed-line paragraphs in output") + else: + print(f" ⚠ {dash_count} dashed-line paragraphs found in output") print(f"\nDone! ✨")