fix: sva now extracts and reads properly
This commit is contained in:
+106
-134
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA).
|
* Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||||
* SVA is a song book with 4 parent parts, each containing multiple songs
|
* SVA is a hierarchical song book with 4 parent parts, each containing
|
||||||
* (sub-chapters). Each song is stored as its own prose_chapters document
|
* multiple songs (sub-chapters). Each song is stored as its own
|
||||||
* with slugs like "1-1", "1-2", "2-1", etc.
|
* 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
|
* When the DB isn't seeded yet, falls back to reading local JSON files
|
||||||
* from lib/jsons/books/sva/.
|
* from lib/jsons/books/sva/.
|
||||||
@@ -130,8 +130,8 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
function readLocalJson<T>(filePath: string): T | null {
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -147,39 +147,19 @@ function getLocalIntroSegments(): SvaIntroSegment[] {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = path.join(introDir, file);
|
const filePath = path.join(introDir, file);
|
||||||
const seg = readLocalJson<SvaIntroSegment>(filePath);
|
const seg = readLocalJson<SvaIntroSegment>(filePath);
|
||||||
if (seg) results.push(seg);
|
if (seg) {
|
||||||
|
results.push({
|
||||||
|
...seg,
|
||||||
|
slug: seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
return results.sort((a, b) => a.order - b.order);
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): SvaIntroSegment | null {
|
|
||||||
const filePath = path.join(SVA_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<SvaIntroSegment>(filePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalSubChapter(slug: string): SvaChapter | null {
|
function getLocalSubChapter(slug: string): SvaChapter | null {
|
||||||
const filePath = path.join(SVA_JSON_DIR, "chapters", `${slug}.json`);
|
const filePath = path.join(SVA_JSON_DIR, "chapters", `${slug}.json`);
|
||||||
const raw = readLocalJson<{
|
return readLocalJson<SvaChapter>(filePath);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -194,7 +174,7 @@ export async function getSvaMeta(): Promise<SvaMeta | null> {
|
|||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||||
if (meta) return meta.data as unknown as SvaMeta;
|
if (meta) return meta.data as unknown as SvaMeta;
|
||||||
} catch {
|
} catch {
|
||||||
// Prisma BSON decode error — fall through to local JSON
|
// fall through to local JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to local JSON
|
// Fallback to local JSON
|
||||||
@@ -227,7 +207,7 @@ export async function getSvaIntroSegments(): Promise<SvaIntroSegment[]> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Prisma BSON decode error — fall through to local JSON
|
// fall through to local JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to local JSON
|
// Fallback to local JSON
|
||||||
@@ -236,7 +216,7 @@ export async function getSvaIntroSegments(): Promise<SvaIntroSegment[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A single intro segment by slug.
|
* A single intro segment by slug.
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
|
||||||
*/
|
*/
|
||||||
export async function getSvaIntroSegment(
|
export async function getSvaIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
@@ -247,25 +227,30 @@ export async function getSvaIntroSegment(
|
|||||||
? slug.replace(/^intro-/, "")
|
? slug.replace(/^intro-/, "")
|
||||||
: slug;
|
: slug;
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
try {
|
||||||
where: {
|
const doc = await prisma.proseChapter.findUnique({
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
where: {
|
||||||
},
|
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (doc) {
|
if (doc) {
|
||||||
return {
|
return {
|
||||||
slug: doc.slug,
|
slug: doc.slug,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
type: doc.type,
|
type: doc.type,
|
||||||
book: doc.book,
|
book: doc.book,
|
||||||
order: doc.order,
|
order: doc.order,
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to local JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to local JSON
|
// Fallback to local JSON
|
||||||
const local = getLocalIntroSegment(rawSlug);
|
const filePath = path.join(SVA_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||||
|
const local = readLocalJson<SvaIntroSegment>(filePath);
|
||||||
if (!local) return null;
|
if (!local) return null;
|
||||||
return {
|
return {
|
||||||
...local,
|
...local,
|
||||||
@@ -275,7 +260,7 @@ export async function getSvaIntroSegment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Chapters (sub-chapters like "1-1", "2-3", etc.)
|
// Chapters (sub-chapters / songs like "1-1", "2-3", etc.)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,7 +303,7 @@ export async function getSvaChapter(slug: string): Promise<SvaChapter | null> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Prisma BSON decode error — fall through to local JSON
|
// fall through to local JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to local JSON
|
// Fallback to local JSON
|
||||||
@@ -360,8 +345,10 @@ export async function getSvaContent(
|
|||||||
/**
|
/**
|
||||||
* Build the full sidebar navigation list with hierarchical structure.
|
* Build the full sidebar navigation list with hierarchical structure.
|
||||||
*
|
*
|
||||||
* Order: front-pages → foreword → introduction
|
* Order: intro segments (front-pages, foreword, introduction)
|
||||||
* → parent-1 (label) → 1-1, 1-2, ... → parent-2 (label) → 2-1, 2-2, ...
|
* → parent-1 (label) → 1-1, 1-2, ...
|
||||||
|
* → parent-2 (label) → 2-1, 2-2, ...
|
||||||
|
* → etc.
|
||||||
*
|
*
|
||||||
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
||||||
* "sub-chapter" items are navigable pages.
|
* "sub-chapter" items are navigable pages.
|
||||||
@@ -378,7 +365,7 @@ export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
|
|||||||
const meta = await getSvaMeta();
|
const meta = await getSvaMeta();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
allDocs = await prisma.proseChapter.findMany({
|
const results = await prisma.proseChapter.findMany({
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
where: { book: BOOK, volume: NO_VOLUME },
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
select: {
|
select: {
|
||||||
@@ -389,41 +376,36 @@ export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
|
|||||||
order: true,
|
order: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
allDocs = results;
|
||||||
} catch {
|
} catch {
|
||||||
// Prisma BSON decode error — build sidebar from meta alone
|
// Prisma error — build sidebar from meta alone
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
// Try building from local JSON
|
// Try building from local JSON
|
||||||
return buildSidebarFromLocal();
|
const localMeta = readLocalJson<SvaMeta>(
|
||||||
|
path.join(SVA_JSON_DIR, "intro.json"),
|
||||||
|
);
|
||||||
|
if (!localMeta) return [];
|
||||||
|
return buildSidebarFromMeta(localMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have no DB docs, build from meta alone
|
||||||
|
if (allDocs.length === 0) {
|
||||||
|
return buildSidebarFromMeta(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: SvaSidebarItem[] = [];
|
const items: SvaSidebarItem[] = [];
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 99)
|
// Intro segments that come BEFORE chapters (order < 99)
|
||||||
if (allDocs.length > 0) {
|
for (const doc of allDocs) {
|
||||||
for (const doc of allDocs) {
|
if (doc.type === "intro-segment" && doc.order < 99) {
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
|
items.push({
|
||||||
items.push({
|
slug: doc.slug,
|
||||||
slug: doc.slug,
|
label: doc.title,
|
||||||
label: doc.title,
|
type: "intro-segment",
|
||||||
type: "intro-segment",
|
parentChapter: null,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +419,7 @@ export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
|
|||||||
parentChapter: ch.number,
|
parentChapter: ch.number,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sub-chapters (songs) under this parent
|
// Sub-chapters under this parent
|
||||||
for (const sub of ch.subChapters) {
|
for (const sub of ch.subChapters) {
|
||||||
items.push({
|
items.push({
|
||||||
slug: sub.slug,
|
slug: sub.slug,
|
||||||
@@ -449,71 +431,61 @@ export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Intro segments that come AFTER chapters (order >= 99)
|
// Intro segments that come AFTER chapters (order >= 99)
|
||||||
if (allDocs.length > 0) {
|
for (const doc of allDocs) {
|
||||||
for (const doc of allDocs) {
|
if (doc.type === "intro-segment" && doc.order >= 99) {
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
|
items.push({
|
||||||
items.push({
|
slug: doc.slug,
|
||||||
slug: doc.slug,
|
label: doc.title,
|
||||||
label: doc.title,
|
type: "intro-segment",
|
||||||
type: "intro-segment",
|
parentChapter: null,
|
||||||
parentChapter: null,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarFromLocal(): SvaSidebarItem[] {
|
/**
|
||||||
|
* Build sidebar items purely from metadata (when DB is unavailable).
|
||||||
|
*/
|
||||||
|
function buildSidebarFromMeta(meta: SvaMeta): SvaSidebarItem[] {
|
||||||
const items: SvaSidebarItem[] = [];
|
const items: SvaSidebarItem[] = [];
|
||||||
|
|
||||||
// Intro segments
|
// Hardcoded intro segments
|
||||||
const introSegs = getLocalIntroSegments();
|
items.push({
|
||||||
for (const seg of introSegs) {
|
slug: "intro-front-pages",
|
||||||
if ((seg.order ?? 0) < 99) {
|
label: "Front Pages",
|
||||||
items.push({
|
type: "intro-segment",
|
||||||
slug: `intro-${seg.slug}`,
|
parentChapter: null,
|
||||||
label: seg.title,
|
});
|
||||||
type: "intro-segment",
|
items.push({
|
||||||
parentChapter: null,
|
slug: "intro-foreword",
|
||||||
});
|
label: "Foreword",
|
||||||
}
|
type: "intro-segment",
|
||||||
}
|
parentChapter: null,
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
slug: "intro-introduction",
|
||||||
|
label: "Introduction",
|
||||||
|
type: "intro-segment",
|
||||||
|
parentChapter: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Load meta for chapter structure
|
for (const ch of meta.chapters) {
|
||||||
const introPath = path.join(SVA_JSON_DIR, "intro.json");
|
items.push({
|
||||||
const meta = readLocalJson<SvaMeta>(introPath);
|
slug: `parent-${ch.number}`,
|
||||||
|
label: `Part ${ch.number}: ${ch.title}`,
|
||||||
|
type: "parent",
|
||||||
|
parentChapter: ch.number,
|
||||||
|
});
|
||||||
|
|
||||||
if (meta?.chapters) {
|
for (const sub of ch.subChapters) {
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
items.push({
|
||||||
slug: `parent-${ch.number}`,
|
slug: sub.slug,
|
||||||
label: `Part ${ch.number}: ${ch.title}`,
|
label: sub.title,
|
||||||
type: "parent",
|
type: "sub-chapter",
|
||||||
parentChapter: ch.number,
|
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 introSegs) {
|
|
||||||
if ((seg.order ?? 0) >= 99) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,24 +93,6 @@ async function insertBatched(
|
|||||||
for (let i = 0; i < docs.length; i++) {
|
for (let i = 0; i < docs.length; i++) {
|
||||||
const sanitized = sanitizeDoc(docs[i]) as Record<string, unknown>;
|
const sanitized = sanitizeDoc(docs[i]) as Record<string, unknown>;
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
await col.insertOne(sanitized);
|
await col.insertOne(sanitized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user