360 lines
9.2 KiB
TypeScript
360 lines
9.2 KiB
TypeScript
/**
|
|
* Data access layer for Other Documents.
|
|
*
|
|
* Calls the BFF API for all data access (no direct Prisma usage).
|
|
* Falls back to reading local JSON files from
|
|
* lib/jsons/other-documents/<slug>/ when the BFF is unavailable.
|
|
*
|
|
* Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula").
|
|
*/
|
|
|
|
import { bff, bffList } from "@/lib/bff";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LOCAL_JSON_DIR = path.join(
|
|
process.cwd(),
|
|
"lib",
|
|
"jsons",
|
|
"other-documents",
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type OtherDocumentMetaData = {
|
|
book: string;
|
|
title: string;
|
|
author: string;
|
|
year: number | null;
|
|
description: string | null;
|
|
totalChapters: number;
|
|
chapters: OtherDocumentChapterEntry[];
|
|
};
|
|
|
|
export type OtherDocumentChapterEntry = {
|
|
number: number;
|
|
title: string;
|
|
};
|
|
|
|
export type OtherDocumentChapterData = {
|
|
book: string;
|
|
chapter: number;
|
|
title: string;
|
|
type: string;
|
|
paragraphs: string[];
|
|
};
|
|
|
|
/** Lightweight summary for the top-level index page. */
|
|
export type OtherDocumentSummary = {
|
|
book: string;
|
|
title: string;
|
|
author: string;
|
|
year: number | null;
|
|
description: string | null;
|
|
totalChapters: number;
|
|
};
|
|
|
|
/** Used in sidebar / table-of-contents navigation. */
|
|
export type OtherDocumentSidebarItem = {
|
|
slug: string;
|
|
label: string;
|
|
type: string;
|
|
chapter: number;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BFF response types (snake_case from Go)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type BffDocumentMeta = {
|
|
id: string;
|
|
book: string;
|
|
data: {
|
|
title: string;
|
|
author: string;
|
|
year?: number | null;
|
|
description?: string | null;
|
|
totalChapters: number;
|
|
chapters: { number: number; title: string }[];
|
|
};
|
|
};
|
|
|
|
type BffChapter = {
|
|
id: string;
|
|
book: string;
|
|
chapter: number;
|
|
title: string;
|
|
type: string;
|
|
slug: string;
|
|
paragraphs: string[];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTML sanitisation (same pattern used across all readers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function sanitizeHtml(html: string): string {
|
|
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
clean = clean.replace(
|
|
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
"",
|
|
);
|
|
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
return clean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mappers (BFF response → frontend types)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mapMetaData(raw: BffDocumentMeta): OtherDocumentMetaData {
|
|
const data =
|
|
raw.data && typeof raw.data === "object"
|
|
? raw.data
|
|
: ({} as BffDocumentMeta["data"]);
|
|
return {
|
|
book: raw.book,
|
|
title: data.title ?? "",
|
|
author: data.author ?? "",
|
|
year: data.year ?? null,
|
|
description: data.description ?? null,
|
|
totalChapters: data.totalChapters ?? 0,
|
|
chapters: (data.chapters ?? []).map((ch) => ({
|
|
number: ch.number,
|
|
title: ch.title,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function mapSummary(raw: BffDocumentMeta): OtherDocumentSummary {
|
|
const data =
|
|
raw.data && typeof raw.data === "object"
|
|
? raw.data
|
|
: ({} as BffDocumentMeta["data"]);
|
|
return {
|
|
book: raw.book,
|
|
title: data.title ?? "",
|
|
author: data.author ?? "",
|
|
year: data.year ?? null,
|
|
description: data.description ?? null,
|
|
totalChapters: data.totalChapters ?? 0,
|
|
};
|
|
}
|
|
|
|
function mapChapter(raw: BffChapter): OtherDocumentChapterData {
|
|
return {
|
|
book: raw.book,
|
|
chapter: raw.chapter,
|
|
title: raw.title,
|
|
type: raw.type,
|
|
paragraphs: (raw.paragraphs ?? []).map(sanitizeHtml),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers: local JSON fallback
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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(): OtherDocumentMetaData[] {
|
|
if (!fs.existsSync(LOCAL_JSON_DIR)) return [];
|
|
|
|
const entries = fs.readdirSync(LOCAL_JSON_DIR).sort();
|
|
const metas: OtherDocumentMetaData[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const introPath = path.join(LOCAL_JSON_DIR, entry, "intro.json");
|
|
const intro = readLocalJson<OtherDocumentMetaData>(introPath);
|
|
if (intro) metas.push(intro);
|
|
}
|
|
|
|
return metas;
|
|
}
|
|
|
|
function getLocalMeta(bookCode: string): OtherDocumentMetaData | null {
|
|
const dir = findLocalDirForBook(bookCode);
|
|
if (!dir) return null;
|
|
return readLocalJson<OtherDocumentMetaData>(
|
|
path.join(LOCAL_JSON_DIR, dir, "intro.json"),
|
|
);
|
|
}
|
|
|
|
function getLocalChapter(
|
|
bookCode: string,
|
|
chapterNum: number,
|
|
): OtherDocumentChapterData | null {
|
|
const dir = findLocalDirForBook(bookCode);
|
|
if (!dir) return null;
|
|
|
|
const filePath = path.join(
|
|
LOCAL_JSON_DIR,
|
|
dir,
|
|
"chapters",
|
|
String(chapterNum).padStart(2, "0") + ".json",
|
|
);
|
|
|
|
const data = readLocalJson<OtherDocumentChapterData>(filePath);
|
|
if (!data) return null;
|
|
|
|
return {
|
|
...data,
|
|
paragraphs: data.paragraphs.map(sanitizeHtml),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* List all other documents (lightweight summaries for the index page).
|
|
*/
|
|
export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> {
|
|
try {
|
|
const results = await bffList<BffDocumentMeta>("/other-documents", {
|
|
tags: ["other-documents"],
|
|
});
|
|
|
|
if (results.length > 0) {
|
|
return results.map(mapSummary);
|
|
}
|
|
} catch {
|
|
// fall through to local JSON
|
|
}
|
|
|
|
const localMetas = getAllLocalMetas();
|
|
return localMetas.map((meta) => ({
|
|
book: meta.book,
|
|
title: meta.title,
|
|
author: meta.author,
|
|
year: meta.year ?? null,
|
|
description: meta.description ?? null,
|
|
totalChapters: meta.totalChapters,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get metadata for a single other document by book code.
|
|
*/
|
|
export async function getOtherDocumentMeta(
|
|
bookCode: string,
|
|
): Promise<OtherDocumentMetaData | null> {
|
|
try {
|
|
const raw = await bff<BffDocumentMeta>(`/other-documents/${bookCode}`, {
|
|
tags: [`other-doc-${bookCode}`],
|
|
});
|
|
|
|
if (raw) {
|
|
return mapMetaData(raw);
|
|
}
|
|
} catch {
|
|
// fall through to local JSON
|
|
}
|
|
|
|
return getLocalMeta(bookCode);
|
|
}
|
|
|
|
/**
|
|
* Get a single chapter from an other document.
|
|
*/
|
|
export async function getOtherDocumentChapter(
|
|
bookCode: string,
|
|
chapterNum: number,
|
|
): Promise<OtherDocumentChapterData | null> {
|
|
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
|
|
|
|
try {
|
|
const raw = await bff<BffChapter>(
|
|
`/other-documents/${bookCode}/chapters/${chapterNum}`,
|
|
{ tags: [`other-doc-${bookCode}`] },
|
|
);
|
|
|
|
if (raw) {
|
|
return mapChapter(raw);
|
|
}
|
|
} catch {
|
|
// fall through to local JSON
|
|
}
|
|
|
|
return getLocalChapter(bookCode, chapterNum);
|
|
}
|
|
|
|
/**
|
|
* Unified content accessor — get content by URL slug.
|
|
*/
|
|
export async function getOtherDocumentContent(
|
|
bookCode: string,
|
|
slug: string,
|
|
): Promise<OtherDocumentChapterData | null> {
|
|
const num = Number(slug);
|
|
if (Number.isNaN(num) || !Number.isInteger(num) || num < 1) return null;
|
|
return getOtherDocumentChapter(bookCode, num);
|
|
}
|
|
|
|
/**
|
|
* Build the sidebar navigation list for an other document.
|
|
*/
|
|
export async function getOtherDocumentSidebarItems(
|
|
bookCode: string,
|
|
): Promise<OtherDocumentSidebarItem[]> {
|
|
const meta = await getOtherDocumentMeta(bookCode);
|
|
if (!meta) return [];
|
|
|
|
return meta.chapters.map((ch) => ({
|
|
slug: String(ch.number),
|
|
label: `${ch.number}. ${ch.title}`,
|
|
type: "chapter",
|
|
chapter: ch.number,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Derive the URL slug from a book code.
|
|
*/
|
|
export function bookCodeToSlug(bookCode: string): string {
|
|
return bookCode;
|
|
}
|
|
|
|
/**
|
|
* Derive the book code from a URL slug.
|
|
*/
|
|
export function slugToBookCode(slug: string): string {
|
|
return slug;
|
|
}
|