Files
spsays-training/lib/lcl.ts
T

446 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Data access layer for Life Comes from Life (LCL).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* LCL is a prose/dialogue book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/lcl/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "lcl";
const LCL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lcl");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LclMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LclChapterEntry[];
};
export type LclChapterEntry = {
number: number;
title: string;
};
export type LclIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LclChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LclSidebarItem = {
/** URL-safe slug: "intro-foreword", "1", "16", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalIntroSegments(): LclIntroSegment[] {
const introDir = path.join(LCL_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LclIntroSegment[] = [];
const files = ["foreword.json", "introduction.json", "about-the-author.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LclIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LclIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LclChapter | null {
const filePath = path.join(
LCL_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LclChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLclMeta(): Promise<LclMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LclMeta;
// Fallback to local JSON
const introPath = path.join(LCL_JSON_DIR, "intro.json");
return readLocalJson<LclMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLclIntroSegments(): Promise<LclIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
*/
export async function getLclIntroSegment(
slug: string,
): Promise<LclIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–16).
*/
export async function getLclChapter(
chapterNum: number,
): Promise<LclChapter | null> {
if (chapterNum < 1 || chapterNum > 16 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-foreword" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "16" → chapter
*/
export async function getLclContent(
slug: string,
): Promise<LclChapter | LclIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLclIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 16) {
return getLclChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: foreword → introduction → chapters 1–16 → about-the-author
*/
export async function getLclSidebarItems(): Promise<LclSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLclMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: LclMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–16 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: LclMeta | null): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-16
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(LCL_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<LclChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter (about-the-author)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
}