11const express = require ( "express" ) ;
22const cors = require ( "cors" ) ;
33const nodemailer = require ( "nodemailer" ) ;
4+ const fs = require ( "fs/promises" ) ;
5+ const path = require ( "path" ) ;
46require ( "dotenv" ) . config ( ) ;
57
68const app = express ( ) ;
@@ -20,6 +22,11 @@ const allowedOrigins = String(process.env.ALLOWED_ORIGINS || "")
2022
2123const origins = allowedOrigins . length ? allowedOrigins : defaultAllowedOrigins ;
2224const 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
2431function normalizeOrigin ( value ) {
2532 try {
@@ -74,6 +81,92 @@ app.use(cors({
7481
7582app . 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 - z A - Z 0 - 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+
77170function 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+
123379app . post ( "/api/contact" , async ( req , res ) => {
124380 const { payload, error } = validatePayload ( req . body || { } ) ;
125381 if ( error ) {
0 commit comments