/** * Data access layer for Magazines (Back to Godhead, etc.). * * Uses the BFF API instead of direct Prisma queries. * Falls back to local JSON files when BFF is unavailable. */ import { bff, bffList } from "@/lib/bff"; import fs from "fs"; import path from "path"; const LOCAL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "magazines"); export type MagazineSubChapter = { slug: string; title: string }; export type MagazineVolumeGroup = { title: string; subChapters: MagazineSubChapter[]; }; export type MagazineChapterEntry = MagazineSubChapter | MagazineVolumeGroup; export type MagazineMetaData = { book: string; title: string; author: string; year: number | null; description: string | null; totalIssues: number; totalArticles: number; flag: string | null; chapters: MagazineChapterEntry[]; }; export type MagazineIssueData = { book: string; volume: number | null; part: number; title: string; slug: string; paragraphs: string[]; }; export type MagazineSummary = { book: string; title: string; author: string; year: number | null; description: string | null; totalIssues: number; flag: string | null; }; export type MagazineSidebarItem = { slug: string; label: string; type: string; }; // --- BFF response types --- type BffMagazineMeta = { id: string; book: string; data: { title: string; author: string; year: number | null; description: string | null; totalIssues: number; totalArticles: number; flag: string | null; chapters: MagazineChapterEntry[]; }; }; type BffMagazineIssue = { book: string; volume: number | null; part: number; title: string; slug: string; paragraphs: string[]; paragraphs_json?: string; }; // --- HTML sanitisation --- function sanitizeHtml(html: string): string { let clean = html.replace(/][\s\S]*?<\/script>/gi, ""); clean = clean.replace(/][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/][\s\S]*?<\/iframe>/gi, ""); clean = clean.replace( /<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi, "", ); clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""'); clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''"); return clean; } function normalizeParagraphs(paragraphs: string[] | unknown): 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); } // --- BFF → domain mappers --- function mapBffMetaToMagazineMetaData(raw: BffMagazineMeta): MagazineMetaData { return { book: raw.book, title: raw.data.title, author: raw.data.author, year: raw.data.year ?? null, description: raw.data.description ?? null, totalIssues: raw.data.totalIssues, totalArticles: raw.data.totalArticles, flag: raw.data.flag ?? null, chapters: raw.data.chapters, }; } function mapBffMetaToSummary(raw: BffMagazineMeta): MagazineSummary { return { book: raw.book, title: raw.data.title, author: raw.data.author, year: raw.data.year ?? null, description: raw.data.description ?? null, totalIssues: raw.data.totalIssues, flag: raw.data.flag ?? null, }; } function mapBffIssue(raw: BffMagazineIssue): MagazineIssueData { // Handle paragraphs_json fallback let paragraphs = raw.paragraphs; if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) { try { paragraphs = JSON.parse(raw.paragraphs_json); } catch { paragraphs = []; } } return { book: raw.book, volume: raw.volume, part: raw.part, title: raw.title, slug: raw.slug, paragraphs: normalizeParagraphs(paragraphs), }; } // --- Local JSON fallback helpers --- 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(filePath: string): T | null { try { if (!fs.existsSync(filePath)) return null; return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; } catch { return null; } } function getAllLocalMetas(): MagazineMetaData[] { if (!fs.existsSync(LOCAL_JSON_DIR)) return []; const entries = fs.readdirSync(LOCAL_JSON_DIR).sort(); const metas: MagazineMetaData[] = []; for (const entry of entries) { const intro = readLocalJson( path.join(LOCAL_JSON_DIR, entry, "intro.json"), ); if (intro) metas.push(intro); } return metas; } function getLocalMeta(bookCode: string): MagazineMetaData | null { const dir = findLocalDirForBook(bookCode); if (!dir) return null; return readLocalJson( path.join(LOCAL_JSON_DIR, dir, "intro.json"), ); } function getLocalIssue( bookCode: string, slug: string, ): MagazineIssueData | null { const dir = findLocalDirForBook(bookCode); if (!dir) return null; const data = readLocalJson( path.join(LOCAL_JSON_DIR, dir, "issues", `${slug}.json`), ); if (!data) return null; return { ...data, paragraphs: data.paragraphs.map(sanitizeHtml) }; } // Public API export async function getAllMagazines(): Promise { try { const results = await bffList("/magazines"); if (results.length > 0) { return results.map(mapBffMetaToSummary); } } catch { /* fall through */ } const localMetas = getAllLocalMetas(); return localMetas.map((meta) => ({ book: meta.book, title: meta.title, author: meta.author, year: meta.year ?? null, description: meta.description ?? null, totalIssues: meta.totalIssues, flag: meta.flag ?? null, })); } export async function getMagazineMeta( bookCode: string, ): Promise { try { const raw = await bff(`/magazines/${bookCode}`); if (raw) return mapBffMetaToMagazineMetaData(raw); } catch { /* fall through */ } return getLocalMeta(bookCode); } export async function getMagazineIssue( bookCode: string, slug: string, ): Promise { if (!slug) return null; try { const raw = await bff( `/magazines/${bookCode}/issues/${slug}`, ); if (raw) return mapBffIssue(raw); } catch { /* fall through */ } return getLocalIssue(bookCode, slug); } export async function getMagazineContent( bookCode: string, slug: string, ): Promise { return getMagazineIssue(bookCode, slug); } function isSubChapter(ch: MagazineChapterEntry): ch is MagazineSubChapter { return "slug" in ch && !("subChapters" in ch); } function hasSubChapters(ch: MagazineChapterEntry): ch is MagazineVolumeGroup { return "subChapters" in ch && Array.isArray(ch.subChapters); } export async function getMagazineSidebarItems( bookCode: string, ): Promise { const meta = await getMagazineMeta(bookCode); if (!meta) return []; const items: MagazineSidebarItem[] = []; function addEntry(entry: MagazineChapterEntry) { if (isSubChapter(entry)) { items.push({ slug: entry.slug, label: entry.title, type: "chapter" }); } else if (hasSubChapters(entry)) { items.push({ slug: "", label: entry.title, type: "heading" }); for (const sc of entry.subChapters) { addEntry(sc); } } } for (const ch of meta.chapters) { addEntry(ch); } return items; } export function bookCodeToSlug(bookCode: string): string { return bookCode; } export function slugToBookCode(slug: string): string { return slug; }