Skip to content

Commit 2a13e09

Browse files
committed
bible.html
1 parent e79669f commit 2a13e09

10 files changed

Lines changed: 3142 additions & 726 deletions

File tree

api/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ ALLOWED_ORIGINS=https://ziongospelministry.org,https://www.ziongospelministry.or
77
SMTP_HOST=smtp.gmail.com
88
SMTP_PORT=587
99
SMTP_SECURE=false
10-
SMTP_USER=your-smtp-user
10+
SMTP_USER=vio
1111
SMTP_PASS=your-smtp-password-or-app-password
1212

1313
# Mail routing.

api/schema/bible_reader_schema.sql

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
-- Bible Reader persistence schema
2+
-- Suitable for PostgreSQL / MySQL style relational databases
3+
4+
CREATE TABLE IF NOT EXISTS users (
5+
id BIGSERIAL PRIMARY KEY,
6+
external_id VARCHAR(64) UNIQUE NOT NULL,
7+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
9+
);
10+
11+
CREATE TABLE IF NOT EXISTS bible_bookmarks (
12+
id BIGSERIAL PRIMARY KEY,
13+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14+
book_slug VARCHAR(64) NOT NULL,
15+
chapter_num INTEGER NOT NULL,
16+
verse_num INTEGER NULL,
17+
note TEXT NULL,
18+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
UNIQUE (user_id, book_slug, chapter_num, verse_num)
20+
);
21+
22+
CREATE TABLE IF NOT EXISTS bible_reading_progress (
23+
id BIGSERIAL PRIMARY KEY,
24+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
25+
book_slug VARCHAR(64) NOT NULL,
26+
chapter_num INTEGER NOT NULL,
27+
verse_num INTEGER NULL,
28+
scroll_pct DECIMAL(5,2) NULL,
29+
last_read_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
30+
UNIQUE (user_id, book_slug, chapter_num)
31+
);
32+
33+
CREATE TABLE IF NOT EXISTS bible_reading_plans (
34+
id BIGSERIAL PRIMARY KEY,
35+
code VARCHAR(40) UNIQUE NOT NULL,
36+
title VARCHAR(120) NOT NULL,
37+
total_days INTEGER NOT NULL,
38+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
39+
);
40+
41+
CREATE TABLE IF NOT EXISTS bible_reading_plan_progress (
42+
id BIGSERIAL PRIMARY KEY,
43+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44+
plan_id BIGINT NOT NULL REFERENCES bible_reading_plans(id) ON DELETE CASCADE,
45+
completed_days INTEGER NOT NULL DEFAULT 0,
46+
completed_chapters JSONB NULL,
47+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
48+
UNIQUE (user_id, plan_id)
49+
);
50+
51+
CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON bible_bookmarks(user_id);
52+
CREATE INDEX IF NOT EXISTS idx_progress_user_last_read ON bible_reading_progress(user_id, last_read_at DESC);
53+
CREATE INDEX IF NOT EXISTS idx_plan_progress_user ON bible_reading_plan_progress(user_id);

api/server.js

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const express = require("express");
22
const cors = require("cors");
33
const nodemailer = require("nodemailer");
4+
const fs = require("fs/promises");
5+
const path = require("path");
46
require("dotenv").config();
57

68
const app = express();
@@ -20,6 +22,11 @@ const allowedOrigins = String(process.env.ALLOWED_ORIGINS || "")
2022

2123
const origins = allowedOrigins.length ? allowedOrigins : defaultAllowedOrigins;
2224
const allowAllOrigins = origins.includes("*");
25+
const repoRoot = path.resolve(__dirname, "..");
26+
const bibleBooksMetaPath = path.join(repoRoot, "assets", "data", "bible-books.json");
27+
const userStateDir = path.join(__dirname, "data");
28+
const userStatePath = path.join(userStateDir, "bible-user-state.json");
29+
const bibleChapterCache = new Map();
2330

2431
function normalizeOrigin(value) {
2532
try {
@@ -74,6 +81,92 @@ app.use(cors({
7481

7582
app.use(express.json({ limit: "100kb" }));
7683

84+
async function readJsonFile(filePath, fallback) {
85+
try {
86+
const content = await fs.readFile(filePath, "utf8");
87+
return JSON.parse(content);
88+
} catch (_err) {
89+
return fallback;
90+
}
91+
}
92+
93+
async function writeJsonFile(filePath, payload) {
94+
await fs.mkdir(path.dirname(filePath), { recursive: true });
95+
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
96+
}
97+
98+
function normalizeBookPayload(payload, bookName) {
99+
if (payload && payload.book && Array.isArray(payload.chapters)) {
100+
const chapterMap = {};
101+
payload.chapters.forEach((ch) => {
102+
const chapterNum = String(ch.chapter || "");
103+
if (!chapterNum) return;
104+
chapterMap[chapterNum] = chapterMap[chapterNum] || {};
105+
(ch.verses || []).forEach((verse) => {
106+
chapterMap[chapterNum][String(verse.verse)] = String(verse.text || "");
107+
});
108+
});
109+
return chapterMap;
110+
}
111+
112+
if (payload && payload[bookName] && typeof payload[bookName] === "object") {
113+
return payload[bookName];
114+
}
115+
116+
return payload && typeof payload === "object" ? payload : {};
117+
}
118+
119+
function numericSort(a, b) {
120+
return Number(a) - Number(b);
121+
}
122+
123+
function sanitizeUserId(input) {
124+
const userId = String(input || "").trim();
125+
if (!/^[a-zA-Z0-9_-]{2,64}$/.test(userId)) return "anonymous";
126+
return userId;
127+
}
128+
129+
function defaultUserBibleState() {
130+
return {
131+
bookmarks: [],
132+
progress: {},
133+
plans: {},
134+
recents: []
135+
};
136+
}
137+
138+
async function getBooksMeta() {
139+
return readJsonFile(bibleBooksMetaPath, []);
140+
}
141+
142+
async function getBookMetaBySlug(bookSlug) {
143+
const books = await getBooksMeta();
144+
return books.find((book) => book.slug === bookSlug) || null;
145+
}
146+
147+
async function getBookChapters(bookSlug) {
148+
if (bibleChapterCache.has(bookSlug)) return bibleChapterCache.get(bookSlug);
149+
150+
const book = await getBookMetaBySlug(bookSlug);
151+
if (!book) return null;
152+
153+
const sourcePath = path.join(repoRoot, "bible-data", book.file);
154+
const payload = await readJsonFile(sourcePath, null);
155+
if (!payload) return null;
156+
157+
const chapterMap = normalizeBookPayload(payload, book.name);
158+
bibleChapterCache.set(bookSlug, chapterMap);
159+
return chapterMap;
160+
}
161+
162+
async function getAllUserState() {
163+
return readJsonFile(userStatePath, {});
164+
}
165+
166+
async function saveAllUserState(payload) {
167+
await writeJsonFile(userStatePath, payload);
168+
}
169+
77170
function buildTransporter() {
78171
const host = process.env.SMTP_HOST;
79172
const portNumber = Number(process.env.SMTP_PORT || 587);
@@ -120,6 +213,169 @@ app.get("/api/health", (_req, res) => {
120213
res.status(200).json({ ok: true });
121214
});
122215

216+
app.get("/api/bible/books", async (_req, res) => {
217+
const books = await getBooksMeta();
218+
res.status(200).json({ ok: true, items: books });
219+
});
220+
221+
app.get("/api/bible/search", async (req, res) => {
222+
const q = String(req.query.q || "").trim().toLowerCase();
223+
const scope = String(req.query.scope || "all").toLowerCase();
224+
const page = Math.max(1, Number(req.query.page || 1));
225+
const limit = Math.min(100, Math.max(1, Number(req.query.limit || 20)));
226+
227+
if (!q) {
228+
res.status(400).json({ ok: false, error: "Search query q is required." });
229+
return;
230+
}
231+
232+
const books = (await getBooksMeta()).filter((book) => {
233+
if (scope === "ot") return book.testament === "ot";
234+
if (scope === "nt") return book.testament === "nt";
235+
return true;
236+
});
237+
238+
const matches = [];
239+
for (const book of books) {
240+
const chapters = await getBookChapters(book.slug);
241+
if (!chapters) continue;
242+
243+
for (const [chapterNum, verses] of Object.entries(chapters)) {
244+
for (const [verseNum, text] of Object.entries(verses || {})) {
245+
const verseText = String(text || "");
246+
if (!verseText.toLowerCase().includes(q)) continue;
247+
matches.push({
248+
book: book.name,
249+
bookSlug: book.slug,
250+
chapter: Number(chapterNum),
251+
verse: Number(verseNum),
252+
text: verseText
253+
});
254+
}
255+
}
256+
}
257+
258+
const total = matches.length;
259+
const start = (page - 1) * limit;
260+
const items = matches.slice(start, start + limit);
261+
res.status(200).json({ ok: true, query: q, scope, page, limit, total, items });
262+
});
263+
264+
app.get("/api/bible/audio/:bookSlug/:chapter", async (req, res) => {
265+
const book = await getBookMetaBySlug(req.params.bookSlug);
266+
const chapter = Number(req.params.chapter || 0);
267+
if (!book || !chapter || chapter < 1 || chapter > book.chapters) {
268+
res.status(404).json({ ok: false, error: "Audio chapter not found." });
269+
return;
270+
}
271+
272+
res.status(200).json({
273+
ok: true,
274+
book: book.name,
275+
chapter,
276+
sourceType: "tts",
277+
streamUrl: null,
278+
supportsBackgroundPlayback: true,
279+
supportsVerseTiming: false,
280+
cues: []
281+
});
282+
});
283+
284+
app.get("/api/bible/:bookSlug", async (req, res) => {
285+
const book = await getBookMetaBySlug(req.params.bookSlug);
286+
if (!book) {
287+
res.status(404).json({ ok: false, error: "Book not found." });
288+
return;
289+
}
290+
291+
const chapters = await getBookChapters(book.slug);
292+
const chapterNumbers = Object.keys(chapters || {}).sort(numericSort).map(Number);
293+
res.status(200).json({ ok: true, item: { ...book, chapterNumbers } });
294+
});
295+
296+
app.get("/api/bible/:bookSlug/:chapter", async (req, res) => {
297+
const book = await getBookMetaBySlug(req.params.bookSlug);
298+
const chapter = Number(req.params.chapter || 0);
299+
if (!book || !chapter) {
300+
res.status(404).json({ ok: false, error: "Chapter not found." });
301+
return;
302+
}
303+
304+
const chapters = await getBookChapters(book.slug);
305+
const chapterMap = chapters ? chapters[String(chapter)] : null;
306+
if (!chapterMap) {
307+
res.status(404).json({ ok: false, error: "Chapter not found." });
308+
return;
309+
}
310+
311+
const verses = Object.keys(chapterMap)
312+
.sort(numericSort)
313+
.map((verse) => ({ verse: Number(verse), text: String(chapterMap[verse] || "") }));
314+
315+
res.status(200).json({
316+
ok: true,
317+
item: {
318+
book: book.name,
319+
bookSlug: book.slug,
320+
chapter,
321+
verses
322+
}
323+
});
324+
});
325+
326+
app.get("/api/bible/:bookSlug/:chapter/:verse", async (req, res) => {
327+
const book = await getBookMetaBySlug(req.params.bookSlug);
328+
const chapter = Number(req.params.chapter || 0);
329+
const verse = Number(req.params.verse || 0);
330+
331+
if (!book || !chapter || !verse) {
332+
res.status(404).json({ ok: false, error: "Verse not found." });
333+
return;
334+
}
335+
336+
const chapters = await getBookChapters(book.slug);
337+
const text = chapters && chapters[String(chapter)] ? chapters[String(chapter)][String(verse)] : null;
338+
if (!text) {
339+
res.status(404).json({ ok: false, error: "Verse not found." });
340+
return;
341+
}
342+
343+
res.status(200).json({
344+
ok: true,
345+
item: {
346+
book: book.name,
347+
bookSlug: book.slug,
348+
chapter,
349+
verse,
350+
text: String(text)
351+
}
352+
});
353+
});
354+
355+
app.get("/api/user/:userId/bible/state", async (req, res) => {
356+
const userId = sanitizeUserId(req.params.userId);
357+
const allState = await getAllUserState();
358+
res.status(200).json({ ok: true, userId, item: allState[userId] || defaultUserBibleState() });
359+
});
360+
361+
app.patch("/api/user/:userId/bible/state", async (req, res) => {
362+
const userId = sanitizeUserId(req.params.userId);
363+
const patch = req.body || {};
364+
365+
const allState = await getAllUserState();
366+
const prev = allState[userId] || defaultUserBibleState();
367+
const next = {
368+
bookmarks: Array.isArray(patch.bookmarks) ? patch.bookmarks : prev.bookmarks,
369+
progress: patch.progress && typeof patch.progress === "object" ? patch.progress : prev.progress,
370+
plans: patch.plans && typeof patch.plans === "object" ? patch.plans : prev.plans,
371+
recents: Array.isArray(patch.recents) ? patch.recents.slice(0, 50) : prev.recents
372+
};
373+
374+
allState[userId] = next;
375+
await saveAllUserState(allState);
376+
res.status(200).json({ ok: true, userId, item: next });
377+
});
378+
123379
app.post("/api/contact", async (req, res) => {
124380
const { payload, error } = validatePayload(req.body || {});
125381
if (error) {

0 commit comments

Comments
 (0)