Files
the-bible/backend/src/index.js
Ryderjj89 f5639af27f Add comprehensive Bible search feature
- Implement backend search engine with indexing and relevance scoring
- Add search API endpoints (/api/search and /api/search/suggestions)
- Create SearchComponent with modal and page views
- Add search button to header navigation
- Support real-time search with debouncing
- Include context verses and search term highlighting
- Add book filtering and mobile-responsive design
- Integrate with existing routing and navigation system
2025-09-15 17:30:00 -04:00

303 lines
8.4 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')));
// Bible data directory
const BIBLE_DATA_DIR = path.join(__dirname, '../../bible-data');
// Initialize search engine
const searchEngine = new BibleSearchEngine(BIBLE_DATA_DIR);
// Helper function to read markdown files
async function readMarkdownFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch (error) {
throw new Error(`Failed to read file: ${filePath}`);
}
}
// Helper function to get all books
async function getBooks() {
try {
const items = await fs.readdir(BIBLE_DATA_DIR);
const bookDirs = [];
for (const item of items) {
const itemPath = path.join(BIBLE_DATA_DIR, item);
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
// Check if directory contains markdown files
try {
const files = await fs.readdir(itemPath);
if (files.some(file => file.endsWith('.md'))) {
bookDirs.push(item);
}
} catch (error) {
// Skip directories we can't read
continue;
}
}
}
return bookDirs;
} catch (error) {
throw new Error('Failed to read bible data directory');
}
}
// Routes
app.get('/health', (req, res) => {
res.json({ status: 'OK', message: 'ESV Bible API is running' });
});
app.get('/books', async (req, res) => {
try {
const books = await getBooks();
res.json({ books });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/books/:book', async (req, res) => {
try {
const { book } = req.params;
const bookDir = path.join(BIBLE_DATA_DIR, 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` });
}
// 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}'` });
}
// 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 });
} catch (error) {
res.status(404).json({ error: `Book '${req.params.book}' not found` });
}
});
app.get('/books/:book/:chapter', async (req, res) => {
try {
const { book, chapter } = req.params;
const chapterPath = path.join(BIBLE_DATA_DIR, book, `${chapter}.md`);
const content = await readMarkdownFile(chapterPath);
res.type('text/markdown').send(content);
} catch (error) {
res.status(404).json({ error: `Chapter ${chapter} not found in book '${book}'` });
}
});
// Search routes
app.get('/api/search', async (req, res) => {
try {
const { q: query, book, limit, context } = 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
};
const results = await searchEngine.search(query, options);
res.json({
query,
results,
total: results.length,
hasMore: results.length === options.limit
});
} 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 } = req.query;
if (!query || query.trim().length < 2) {
return res.json({ suggestions: [] });
}
const suggestions = await searchEngine.getSearchSuggestions(query, parseInt(limit) || 10);
res.json({ suggestions });
} 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 } = 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,
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(`ESV Bible API server running on port ${PORT}`);
console.log(`Bible data directory: ${BIBLE_DATA_DIR}`);
});