453 lines
14 KiB
JavaScript
453 lines
14 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const helmet = require('helmet');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const { configureAuth, requireAuth, optionalAuth } = require('./auth');
|
|
const { preferencesOps, favoritesOps } = require('./database');
|
|
const BibleSearchEngine = require('./search');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Middleware
|
|
app.use(helmet({
|
|
contentSecurityPolicy: false,
|
|
crossOriginOpenerPolicy: false,
|
|
crossOriginEmbedderPolicy: false,
|
|
originAgentCluster: false
|
|
}));
|
|
app.use(cors({
|
|
origin: process.env.FRONTEND_URL || true,
|
|
credentials: true,
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'],
|
|
exposedHeaders: ['Set-Cookie']
|
|
}));
|
|
app.use(express.json());
|
|
|
|
// Configure authentication
|
|
configureAuth(app);
|
|
|
|
// Serve static files from the React build
|
|
app.use(express.static(path.join(__dirname, '../../frontend/build')));
|
|
|
|
// Serve logos folder as static files
|
|
app.use('/logos', express.static(path.join(__dirname, '../../frontend/logos')));
|
|
|
|
// Bible data directories
|
|
const ESV_DATA_DIR = path.join(__dirname, '../../ESV'); // ESV local files
|
|
const NKJV_DATA_DIR = path.join(__dirname, '../../NKJV'); // NKJV local files
|
|
const NLT_DATA_DIR = path.join(__dirname, '../../NLT'); // NLT local files
|
|
const CSB_DATA_DIR = path.join(__dirname, '../../CSB'); // CSB local files
|
|
|
|
// Initialize search engines for each version
|
|
let esvSearchEngine = null;
|
|
let nkjvSearchEngine = null;
|
|
let nltSearchEngine = null;
|
|
let csbSearchEngine = null;
|
|
|
|
try {
|
|
if (ESV_DATA_DIR) {
|
|
esvSearchEngine = new BibleSearchEngine(ESV_DATA_DIR);
|
|
}
|
|
} catch (error) {
|
|
console.log('ESV search engine failed to initialize (data directory may not exist):', error.message);
|
|
}
|
|
|
|
try {
|
|
nkjvSearchEngine = new BibleSearchEngine(NKJV_DATA_DIR);
|
|
} catch (error) {
|
|
console.log('NKJV search engine failed to initialize:', error.message);
|
|
}
|
|
|
|
try {
|
|
nltSearchEngine = new BibleSearchEngine(NLT_DATA_DIR);
|
|
} catch (error) {
|
|
console.log('NLT search engine failed to initialize:', error.message);
|
|
}
|
|
|
|
try {
|
|
csbSearchEngine = new BibleSearchEngine(CSB_DATA_DIR);
|
|
} catch (error) {
|
|
console.log('CSB search engine failed to initialize:', error.message);
|
|
}
|
|
|
|
// Helper function to get data directory for version
|
|
function getDataDir(version) {
|
|
if (version === 'esv' && esvSearchEngine) return ESV_DATA_DIR;
|
|
if (version === 'nkjv') return NKJV_DATA_DIR;
|
|
if (version === 'nlt' && nltSearchEngine) return NLT_DATA_DIR;
|
|
if (version === 'csb' && csbSearchEngine) return CSB_DATA_DIR;
|
|
return esvSearchEngine ? ESV_DATA_DIR : NKJV_DATA_DIR; // default to available version
|
|
}
|
|
|
|
// Helper function to read markdown files
|
|
async function readMarkdownFile(filePath) {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
// Remove BOM if present and normalize encoding issues
|
|
return content.replace(/^\uFEFF/, ''); // Remove UTF-8 BOM
|
|
} catch (error) {
|
|
throw new Error(`Failed to read file: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
// Helper function to get all books for a version
|
|
async function getBooks(version = 'esv') {
|
|
try {
|
|
const dataDir = getDataDir(version);
|
|
console.log(`Attempting to read books from: ${dataDir} for version: ${version}`);
|
|
|
|
const items = await fs.readdir(dataDir);
|
|
console.log(`Found ${items.length} items in ${dataDir}`);
|
|
|
|
const bookDirs = [];
|
|
|
|
for (const item of items) {
|
|
const itemPath = path.join(dataDir, item);
|
|
const stat = await fs.stat(itemPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
console.log(`Checking directory: ${item}`);
|
|
// Check if directory contains markdown files
|
|
try {
|
|
const files = await fs.readdir(itemPath);
|
|
if (files.some(file => file.endsWith('.md'))) {
|
|
bookDirs.push(item);
|
|
console.log(`Added book: ${item} (${files.length} files)`);
|
|
}
|
|
} catch (error) {
|
|
// Skip directories we can't read
|
|
console.log(`Could not read files in ${item}: ${error.message}`);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Found ${bookDirs.length} books for version ${version}:`, bookDirs.slice(0, 5));
|
|
return bookDirs;
|
|
} catch (error) {
|
|
console.error(`Failed to read bible data directory for version ${version}:`, error.message);
|
|
throw new Error(`Failed to read bible data directory for version ${version}`);
|
|
}
|
|
}
|
|
|
|
// Routes
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'OK', message: 'The Bible API is running' });
|
|
});
|
|
|
|
app.get('/versions', (req, res) => {
|
|
const availableVersions = [];
|
|
if (esvSearchEngine) availableVersions.push({ id: 'esv', name: 'ESV - English Standard Version' });
|
|
availableVersions.push({ id: 'nkjv', name: 'NKJV - New King James Version' });
|
|
if (nltSearchEngine) availableVersions.push({ id: 'nlt', name: 'NLT - New Living Translation' });
|
|
if (csbSearchEngine) availableVersions.push({ id: 'csb', name: 'CSB - Christian Standard Bible' });
|
|
|
|
res.json({ versions: availableVersions });
|
|
});
|
|
|
|
app.get('/books', async (req, res) => {
|
|
try {
|
|
const { version = 'esv' } = req.query;
|
|
const books = await getBooks(version);
|
|
res.json({ books, version });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/books/:book', async (req, res) => {
|
|
try {
|
|
const { book } = req.params;
|
|
const { version = 'esv' } = req.query;
|
|
const dataDir = getDataDir(version);
|
|
const bookDir = path.join(dataDir, book);
|
|
|
|
// Check if book directory exists
|
|
const stat = await fs.stat(bookDir);
|
|
if (!stat.isDirectory()) {
|
|
return res.status(404).json({ error: `Book '${book}' not found in version '${version}'` });
|
|
}
|
|
|
|
// Get all chapter files
|
|
const files = await fs.readdir(bookDir);
|
|
const chapterFiles = files.filter(file => file.endsWith('.md')).sort();
|
|
|
|
if (chapterFiles.length === 0) {
|
|
return res.status(404).json({ error: `No chapters found for book '${book}' in version '${version}'` });
|
|
}
|
|
|
|
// Extract chapter numbers from filenames (e.g., "Chapter_01.md" -> "1")
|
|
const chapters = chapterFiles.map(file => {
|
|
const match = file.match(/Chapter_(\d+)\.md$/);
|
|
return match ? parseInt(match[1], 10).toString() : null;
|
|
}).filter(Boolean);
|
|
|
|
res.json({ chapters, version });
|
|
} catch (error) {
|
|
res.status(404).json({ error: `Book '${req.params.book}' not found in version '${req.query.version || 'esv'}'` });
|
|
}
|
|
});
|
|
|
|
app.get('/books/:book/:chapter', async (req, res) => {
|
|
try {
|
|
const { book, chapter } = req.params;
|
|
const { version = 'esv' } = req.query;
|
|
|
|
// Check if chapter already includes "Chapter_" prefix (from frontend)
|
|
let chapterFileName;
|
|
if (chapter.startsWith('Chapter_')) {
|
|
chapterFileName = `${chapter}.md`;
|
|
} else {
|
|
// Legacy support: if just a number, format with leading zero
|
|
const paddedChapter = chapter.padStart(2, '0');
|
|
chapterFileName = `Chapter_${paddedChapter}.md`;
|
|
}
|
|
|
|
const dataDir = getDataDir(version);
|
|
const chapterPath = path.join(dataDir, book, chapterFileName);
|
|
|
|
const content = await readMarkdownFile(chapterPath);
|
|
res.type('text/markdown').send(content);
|
|
} catch (error) {
|
|
res.status(404).json({ error: `Chapter ${req.params.chapter} not found in book '${req.params.book}' for version '${req.query.version || 'esv'}'` });
|
|
}
|
|
});
|
|
|
|
// Search routes
|
|
app.get('/api/search', async (req, res) => {
|
|
try {
|
|
const { q: query, book, limit, context, version = 'esv' } = req.query;
|
|
|
|
if (!query || query.trim().length < 2) {
|
|
return res.status(400).json({ error: 'Query must be at least 2 characters long' });
|
|
}
|
|
|
|
const options = {
|
|
bookFilter: book || null,
|
|
limit: parseInt(limit) || 50,
|
|
includeContext: context !== 'false',
|
|
contextSize: 2
|
|
};
|
|
|
|
let results = [];
|
|
let searchVersion = version;
|
|
|
|
if (version === 'all') {
|
|
// Search across all available versions
|
|
const allResults = [];
|
|
const searchEngines = [
|
|
{ engine: esvSearchEngine, version: 'esv' },
|
|
{ engine: nkjvSearchEngine, version: 'nkjv' },
|
|
{ engine: nltSearchEngine, version: 'nlt' },
|
|
{ engine: csbSearchEngine, version: 'csb' }
|
|
].filter(item => item.engine); // Only include engines that are available
|
|
|
|
for (const { engine, version: engineVersion } of searchEngines) {
|
|
try {
|
|
const versionResults = await engine.search(query, { ...options, limit: Math.ceil(options.limit / searchEngines.length) });
|
|
// Add version info to each result
|
|
const resultsWithVersion = versionResults.map(result => ({ ...result, searchVersion: engineVersion }));
|
|
allResults.push(...resultsWithVersion);
|
|
} catch (error) {
|
|
console.log(`Search failed for ${engineVersion}:`, error.message);
|
|
}
|
|
}
|
|
|
|
// Sort by relevance and limit total results
|
|
results = allResults
|
|
.sort((a, b) => b.relevance - a.relevance)
|
|
.slice(0, options.limit);
|
|
|
|
searchVersion = 'all';
|
|
} else {
|
|
// Search in specific version
|
|
let searchEngine;
|
|
if (version === 'esv' && esvSearchEngine) {
|
|
searchEngine = esvSearchEngine;
|
|
} else if (version === 'nlt' && nltSearchEngine) {
|
|
searchEngine = nltSearchEngine;
|
|
} else if (version === 'csb' && csbSearchEngine) {
|
|
searchEngine = csbSearchEngine;
|
|
} else {
|
|
searchEngine = nkjvSearchEngine; // default fallback
|
|
}
|
|
|
|
results = await searchEngine.search(query, options);
|
|
}
|
|
|
|
res.json({
|
|
query,
|
|
results,
|
|
total: results.length,
|
|
hasMore: results.length === options.limit,
|
|
version: searchVersion
|
|
});
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
res.status(500).json({ error: 'Search failed. Please try again.' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/search/suggestions', async (req, res) => {
|
|
try {
|
|
const { q: query, limit, version = 'esv' } = req.query;
|
|
|
|
if (!query || query.trim().length < 2) {
|
|
return res.json({ suggestions: [] });
|
|
}
|
|
|
|
// Get the appropriate search engine for the version
|
|
let searchEngine;
|
|
if (version === 'esv' && esvSearchEngine) {
|
|
searchEngine = esvSearchEngine;
|
|
} else if (version === 'nlt' && nltSearchEngine) {
|
|
searchEngine = nltSearchEngine;
|
|
} else if (version === 'csb' && csbSearchEngine) {
|
|
searchEngine = csbSearchEngine;
|
|
} else {
|
|
searchEngine = nkjvSearchEngine; // default fallback
|
|
}
|
|
|
|
const suggestions = await searchEngine.getSearchSuggestions(query, parseInt(limit) || 10);
|
|
|
|
res.json({ suggestions, version });
|
|
} catch (error) {
|
|
console.error('Search suggestions error:', error);
|
|
res.status(500).json({ error: 'Failed to get search suggestions' });
|
|
}
|
|
});
|
|
|
|
// User preferences routes
|
|
app.get('/api/preferences', requireAuth, (req, res) => {
|
|
preferencesOps.getPreferences(req.user.id, (err, preferences) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Failed to get preferences' });
|
|
}
|
|
res.json({
|
|
font_size: preferences?.font_size || 'medium',
|
|
dark_mode: !!preferences?.dark_mode
|
|
});
|
|
});
|
|
});
|
|
|
|
app.put('/api/preferences', requireAuth, (req, res) => {
|
|
const { font_size, dark_mode } = req.body;
|
|
|
|
// Validate font_size
|
|
if (font_size && !['small', 'medium', 'large'].includes(font_size)) {
|
|
return res.status(400).json({ error: 'Invalid font_size' });
|
|
}
|
|
|
|
preferencesOps.updatePreferences(req.user.id, { font_size, dark_mode }, (err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Failed to update preferences' });
|
|
}
|
|
res.json({ message: 'Preferences updated successfully' });
|
|
});
|
|
});
|
|
|
|
// Favorites routes
|
|
app.get('/api/favorites', requireAuth, (req, res) => {
|
|
favoritesOps.getFavorites(req.user.id, (err, favorites) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Failed to get favorites' });
|
|
}
|
|
res.json({ favorites });
|
|
});
|
|
});
|
|
|
|
app.post('/api/favorites', requireAuth, (req, res) => {
|
|
const { book, chapter, verse_start, verse_end, note, version } = req.body;
|
|
|
|
// Book is required, but chapter is optional (for book-level favorites)
|
|
if (!book) {
|
|
return res.status(400).json({ error: 'Book is required' });
|
|
}
|
|
|
|
const favorite = {
|
|
book,
|
|
chapter: chapter || null,
|
|
verse_start: verse_start || null,
|
|
verse_end: verse_end || null,
|
|
version: version || 'esv',
|
|
note: note || null
|
|
};
|
|
|
|
favoritesOps.addFavorite(req.user.id, favorite, function(err) {
|
|
if (err) {
|
|
if (err.code === 'SQLITE_CONSTRAINT') {
|
|
return res.status(409).json({ error: 'Favorite already exists' });
|
|
}
|
|
return res.status(500).json({ error: 'Failed to add favorite' });
|
|
}
|
|
res.json({
|
|
message: 'Favorite added successfully',
|
|
id: this.lastID
|
|
});
|
|
});
|
|
});
|
|
|
|
app.delete('/api/favorites/:id', requireAuth, (req, res) => {
|
|
const favoriteId = req.params.id;
|
|
|
|
favoritesOps.removeFavorite(req.user.id, favoriteId, (err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Failed to remove favorite' });
|
|
}
|
|
res.json({ message: 'Favorite removed successfully' });
|
|
});
|
|
});
|
|
|
|
app.get('/api/favorites/check', requireAuth, (req, res) => {
|
|
const { book, chapter, verse_start, verse_end } = req.query;
|
|
|
|
if (!book) {
|
|
return res.status(400).json({ error: 'Book is required' });
|
|
}
|
|
|
|
favoritesOps.isFavorited(
|
|
req.user.id,
|
|
book,
|
|
chapter || null,
|
|
verse_start || null,
|
|
verse_end || null,
|
|
(err, isFavorited) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Failed to check favorite status' });
|
|
}
|
|
res.json({ isFavorited });
|
|
}
|
|
);
|
|
});
|
|
|
|
// Catch-all handler: send back React's index.html for client-side routing
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../../frontend/build/index.html'));
|
|
});
|
|
|
|
// Error handling middleware
|
|
app.use((err, req, res, next) => {
|
|
console.error(err.stack);
|
|
res.status(500).json({ error: 'Something went wrong!' });
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`The Bible API server running on port ${PORT}`);
|
|
if (esvSearchEngine) {
|
|
console.log(`ESV data directory: ${ESV_DATA_DIR}`);
|
|
} else {
|
|
console.log(`ESV data not available - mdbible not found`);
|
|
}
|
|
console.log(`NKJV data directory: ${NKJV_DATA_DIR}`);
|
|
if (nltSearchEngine) {
|
|
console.log(`NLT data directory: ${NLT_DATA_DIR}`);
|
|
}
|
|
if (csbSearchEngine) {
|
|
console.log(`CSB data directory: ${CSB_DATA_DIR}`);
|
|
}
|
|
});
|