Files
spsays-training/lib/pqpa.ts
T
2026-04-17 22:51:58 +05:30

337 lines
8.9 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 Perfect Questions Perfect Answers (PQPA).
*
* Reads from MongoDB via Prisma, same as the rest of the app.
* PQPA is a prose/dialogue book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* 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";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
const BOOK = "pqpa";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PqpaMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: PqpaChapterEntry[];
};
export type PqpaChapterEntry = {
number: number;
title: string;
};
export type PqpaIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type PqpaChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PqpaSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "9", "concluding-words" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" | "conclusion" */
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);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPqpaMeta(): Promise<PqpaMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as PqpaMeta;
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getPqpaIntroSegments(): Promise<PqpaIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getPqpaIntroSegment(
slug: string,
): Promise<PqpaIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–9).
*/
export async function getPqpaChapter(
chapterNum: number,
): Promise<PqpaChapter | null> {
if (chapterNum < 1 || chapterNum > 9 || !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 null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
/**
* The concluding words section.
*/
export async function getPqpaConcludingWords(): Promise<PqpaChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: "concluding-words",
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "9" → chapter
* "concluding-words" → concluding section
*/
export async function getPqpaContent(
slug: string,
): Promise<PqpaChapter | PqpaIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getPqpaIntroSegment(slug);
}
if (slug === "concluding-words") {
return getPqpaConcludingWords();
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 9) {
return getPqpaChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–9 → concluding-words → about-the-author
*/
export async function getPqpaSidebarItems(): Promise<PqpaSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPqpaMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
if (!meta) return [];
const items: PqpaSidebarItem[] = [];
// 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–9 (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,
});
}
// Concluding words
const cw = allDocs.find((d) => d.type === "conclusion");
if (cw) {
items.push({
slug: cw.slug,
label: cw.title,
type: "conclusion",
chapter: null,
});
}
// 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;
}