feat: letters added

This commit is contained in:
gosangam
2026-05-04 00:08:31 +05:30
parent 35d1d14f37
commit 401766928a
20 changed files with 4272 additions and 26 deletions
+249
View File
@@ -0,0 +1,249 @@
/**
* Data access layer for Letters.
*
* Provides queries for browsing letters by year, recipient, and location.
* Uses the Prisma Letter model (collection: letters).
*
* Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]).
* The recipient list aggregates individual names across all letters,
* and searching for "Mukunda" finds all letters where Mukunda is
* ANY recipient (individual or group).
*/
import { prisma } from "@/lib/db";
// Types
export type LetterSummary = {
slug: string;
recipients: string[];
recipientDisplay: string;
date: string | null;
year: number | null;
month: number | null;
location: string | null;
};
export type LetterFull = {
slug: string;
recipients: string[];
recipientDisplay: string;
date: string | null;
dateRef: string | null;
year: number | null;
month: number | null;
day: number | null;
location: string | null;
salutation: string | null;
paragraphs: string[];
};
export type YearGroup = {
year: number;
count: number;
};
export type RecipientGroup = {
name: string;
count: number;
};
export type LocationGroup = {
name: string;
count: number;
};
// Sanitize
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, "");
return clean;
}
// --- Index/overview queries ---
export async function getLetterYears(): Promise<YearGroup[]> {
const letters = await prisma.letter.findMany({
where: { year: { not: null } },
select: { year: true },
});
const counts: Record<number, number> = {};
for (const l of letters) {
if (l.year) {
counts[l.year] = (counts[l.year] || 0) + 1;
}
}
return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count }))
.filter((g) => g.year >= 1940 && g.year <= 1980)
.sort((a, b) => a.year - b.year);
}
/**
* Get all unique individual recipients with letter counts.
*
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
* This function aggregates across all letters so that "Mukunda" appears
* once with count = total letters where Mukunda is ANY recipient.
*/
export async function getLetterRecipients(): Promise<RecipientGroup[]> {
const letters = await prisma.letter.findMany({
select: { recipients: true },
});
const counts: Record<string, number> = {};
for (const l of letters) {
for (const r of l.recipients) {
counts[r] = (counts[r] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
export async function getLetterLocations(): Promise<LocationGroup[]> {
const letters = await prisma.letter.findMany({
where: { location: { not: null } },
select: { location: true },
});
const counts: Record<string, number> = {};
for (const l of letters) {
if (l.location) {
counts[l.location] = (counts[l.location] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
// --- Letter list queries ---
export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({
where: { year },
orderBy: [{ month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
});
return letters;
}
/**
* Get all letters where the given name is ANY recipient.
*
* Uses Prisma's `has` filter on the recipients array.
* Searching for "Mukunda" returns letters addressed to:
* - "Mukunda" (individual)
* - "Mukunda, Janaki" (group containing Mukunda)
* - "Yamuna, Mukunda, Syamasundara" (group containing Mukunda)
*/
export async function getLettersByRecipient(
recipient: string,
): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({
where: { recipients: { has: recipient } },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
});
return letters;
}
export async function getLettersByLocation(
location: string,
): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({
where: { location },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
});
return letters;
}
// --- Single letter ---
export async function getLetter(slug: string): Promise<LetterFull | null> {
const letter = await prisma.letter.findUnique({ where: { slug } });
if (!letter) return null;
return {
slug: letter.slug,
recipients: letter.recipients,
recipientDisplay: letter.recipientDisplay,
date: letter.date,
dateRef: letter.dateRef,
year: letter.year,
month: letter.month,
day: letter.day,
location: letter.location,
salutation: letter.salutation,
paragraphs: letter.paragraphs.map(sanitizeHtml),
};
}
// --- Navigation helpers ---
export async function getAdjacentLetters(
slug: string,
year: number | null,
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
if (!year) return { prev: null, next: null };
const yearLetters = await prisma.letter.findMany({
where: { year },
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
});
const idx = yearLetters.findIndex((l) => l.slug === slug);
return {
prev: idx > 0 ? yearLetters[idx - 1] : null,
next: idx < yearLetters.length - 1 ? yearLetters[idx + 1] : null,
};
}
export async function getTotalLetterCount(): Promise<number> {
return prisma.letter.count();
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Data access layer for Magazines (Back to Godhead, etc.).
*
* Uses dedicated Prisma models:
* - MagazineMeta (collection: magazine_meta)
* - MagazineIssue (collection: magazine_issues)
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
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;
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;
};
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;
}
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);
}
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(): 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<MagazineMetaData>(
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<MagazineMetaData>(
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<MagazineIssueData>(
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<MagazineSummary[]> {
try {
const results = await prisma.magazineMeta.findMany({
orderBy: { book: "asc" },
});
if (results.length > 0) {
return results.map((row) => {
const data = row.data as unknown as MagazineMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalIssues: data.totalIssues,
flag: data.flag ?? null,
};
});
}
} 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<MagazineMetaData | null> {
try {
const row = await prisma.magazineMeta.findUnique({
where: { book: bookCode },
});
if (row) return row.data as unknown as MagazineMetaData;
} catch {
/* fall through */
}
return getLocalMeta(bookCode);
}
export async function getMagazineIssue(
bookCode: string,
slug: string,
): Promise<MagazineIssueData | null> {
if (!slug) return null;
try {
const doc = await prisma.magazineIssue.findUnique({
where: { book_slug: { book: bookCode, slug } },
});
if (doc) {
// Support both paragraphsJson (string) and paragraphs (array) formats.
// The seed script stores paragraphs as a JSON string to avoid
// MongoDB BSON wire protocol issues with large string arrays.
let paras: string[];
if (doc.paragraphsJson) {
try {
paras = JSON.parse(doc.paragraphsJson);
} catch {
paras = [];
}
} else {
paras = normalizeParagraphs(doc.paragraphs);
}
return {
book: doc.book,
volume: doc.volume,
part: doc.part,
title: doc.title,
slug: doc.slug,
paragraphs: paras,
};
}
} catch {
/* fall through */
}
return getLocalIssue(bookCode, slug);
}
export async function getMagazineContent(
bookCode: string,
slug: string,
): Promise<MagazineIssueData | null> {
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<MagazineSidebarItem[]> {
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;
}