diff --git a/backend/src/index.js b/backend/src/index.js
index 8c42fecd..4cc0334b 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -41,6 +41,51 @@ 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
+// Simple LRU Cache for chapter content
+class LRUCache {
+ constructor(maxSize = 100) {
+ this.maxSize = maxSize;
+ this.cache = new Map();
+ }
+
+ get(key) {
+ if (!this.cache.has(key)) return null;
+
+ // Move to end (most recently used)
+ const value = this.cache.get(key);
+ this.cache.delete(key);
+ this.cache.set(key, value);
+ return value;
+ }
+
+ set(key, value) {
+ // Remove if exists (to update position)
+ if (this.cache.has(key)) {
+ this.cache.delete(key);
+ }
+
+ // Add to end (most recently used)
+ this.cache.set(key, value);
+
+ // Evict oldest if over capacity
+ if (this.cache.size > this.maxSize) {
+ const firstKey = this.cache.keys().next().value;
+ this.cache.delete(firstKey);
+ }
+ }
+
+ clear() {
+ this.cache.clear();
+ }
+
+ get size() {
+ return this.cache.size;
+ }
+}
+
+// Initialize chapter cache (stores ~100 most recent chapters, ~1MB memory)
+const chapterCache = new LRUCache(100);
+
// Initialize search engines for each version
let esvSearchEngine = null;
let nkjvSearchEngine = null;
@@ -82,12 +127,24 @@ function getDataDir(version) {
return esvSearchEngine ? ESV_DATA_DIR : NKJV_DATA_DIR; // default to available version
}
-// Helper function to read markdown files
+// Helper function to read markdown files with caching
async function readMarkdownFile(filePath) {
+ // Check cache first
+ const cached = chapterCache.get(filePath);
+ if (cached) {
+ return cached;
+ }
+
+ // Read from disk and cache
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
+ const cleanContent = content.replace(/^\uFEFF/, ''); // Remove UTF-8 BOM
+
+ // Store in cache
+ chapterCache.set(filePath, cleanContent);
+
+ return cleanContent;
} catch (error) {
throw new Error(`Failed to read file: ${filePath}`);
}
@@ -152,6 +209,10 @@ app.get('/books', async (req, res) => {
try {
const { version = 'esv' } = req.query;
const books = await getBooks(version);
+
+ // Cache books list for 24 hours (static content)
+ res.set('Cache-Control', 'public, max-age=86400');
+
res.json({ books, version });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -187,6 +248,9 @@ app.get('/books/:book', async (req, res) => {
const chapters = chapterNumbers.map(num => num.toString());
+ // Cache chapters list for 24 hours (static content)
+ res.set('Cache-Control', 'public, max-age=86400');
+
res.json({ chapters, version });
} catch (error) {
res.status(404).json({ error: `Book '${req.params.book}' not found in version '${req.query.version || 'esv'}'` });
@@ -212,6 +276,10 @@ app.get('/books/:book/:chapter', async (req, res) => {
const chapterPath = path.join(dataDir, book, chapterFileName);
const content = await readMarkdownFile(chapterPath);
+
+ // Cache chapter content for 24 hours with immutable flag (never changes)
+ res.set('Cache-Control', 'public, max-age=86400, immutable');
+
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'}'` });
@@ -238,8 +306,7 @@ app.get('/api/search', async (req, res) => {
let searchVersion = version;
if (version === 'all') {
- // Search across all available versions
- const allResults = [];
+ // Search across all available versions IN PARALLEL
const searchEngines = [
{ engine: esvSearchEngine, version: 'esv' },
{ engine: nkjvSearchEngine, version: 'nkjv' },
@@ -247,16 +314,21 @@ app.get('/api/search', async (req, res) => {
{ 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);
- }
- }
+ // Execute all searches in parallel with Promise.all
+ const searchPromises = searchEngines.map(({ engine, version: engineVersion }) =>
+ engine.search(query, { ...options, limit: Math.ceil(options.limit / searchEngines.length) })
+ .then(versionResults =>
+ // Add version info to each result
+ versionResults.map(result => ({ ...result, searchVersion: engineVersion }))
+ )
+ .catch(error => {
+ console.log(`Search failed for ${engineVersion}:`, error.message);
+ return []; // Return empty array on error
+ })
+ );
+
+ const allResultArrays = await Promise.all(searchPromises);
+ const allResults = allResultArrays.flat();
// Sort by relevance and limit total results
results = allResults
diff --git a/backend/src/search.js b/backend/src/search.js
index 048fec9a..77ae6829 100644
--- a/backend/src/search.js
+++ b/backend/src/search.js
@@ -55,35 +55,40 @@ class BibleSearchEngine {
return allVerses.slice(start, end);
}
- // Calculate relevance score for search results
+ // Calculate relevance score for search results (optimized O(n) algorithm)
calculateRelevance(text, query) {
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
-
+
let score = 0;
-
+
// Exact phrase match gets highest score
if (lowerText.includes(lowerQuery)) {
score += 100;
}
-
- // Word matches
- const queryWords = lowerQuery.split(/\s+/);
+
+ // Word matches - optimized to O(n+m) using Set
+ const queryWords = new Set(lowerQuery.split(/\s+/));
const textWords = lowerText.split(/\s+/);
-
- for (const queryWord of queryWords) {
- for (const textWord of textWords) {
- if (textWord === queryWord) {
- score += 50; // Exact word match
- } else if (textWord.includes(queryWord)) {
- score += 25; // Partial word match
+
+ // Single pass through text words (O(n) instead of O(n*m))
+ for (const textWord of textWords) {
+ if (queryWords.has(textWord)) {
+ score += 50; // Exact word match
+ } else {
+ // Check for partial matches (only if not already matched)
+ for (const queryWord of queryWords) {
+ if (textWord.includes(queryWord) && queryWord.length > 2) {
+ score += 25; // Partial word match
+ break; // Only count once per text word
+ }
}
}
}
-
+
// Boost score for shorter verses (more focused results)
if (text.length < 100) score += 10;
-
+
return score;
}
@@ -219,19 +224,23 @@ class BibleSearchEngine {
.slice(0, limit);
}
- // Highlight search terms in text
+ // Highlight search terms in text (optimized with single regex)
highlightText(text, query) {
if (!query) return text;
-
- const queryWords = query.toLowerCase().split(/\s+/);
- let highlightedText = text;
-
- for (const word of queryWords) {
- const regex = new RegExp(`(${word})`, 'gi');
- highlightedText = highlightedText.replace(regex, '$1');
- }
-
- return highlightedText;
+
+ const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
+ if (queryWords.length === 0) return text;
+
+ // Escape special regex characters and create single regex with alternation
+ const escapedWords = queryWords.map(word =>
+ word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ );
+
+ // Single regex compilation (more efficient than N separate regexes)
+ const pattern = escapedWords.join('|');
+ const regex = new RegExp(`(${pattern})`, 'gi');
+
+ return text.replace(regex, '$1');
}
// Get search suggestions (for autocomplete)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c8d1da29..8f2d4273 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -28,6 +28,7 @@ function App() {
const [selectedVersion, setSelectedVersion] = useState(''); // Empty means no version selected yet
const [versionSelected, setVersionSelected] = useState(false); // Track if version has been chosen
const [availableVersions, setAvailableVersions] = useState([]);
+ const [favorites, setFavorites] = useState([]); // Centralized favorites state
const location = useLocation();
const navigate = useNavigate();
@@ -70,9 +71,19 @@ function App() {
useEffect(() => {
if (user) {
loadUserPreferences();
+ loadFavorites(); // Load favorites once when user logs in
+ } else {
+ setFavorites([]); // Clear favorites when user logs out
}
}, [user]);
+ // Reload favorites when version changes (to ensure version-filtered favorites are current)
+ useEffect(() => {
+ if (user && selectedVersion) {
+ loadFavorites();
+ }
+ }, [selectedVersion]);
+
// Load user preferences from database
const loadUserPreferences = async () => {
if (!user) return;
@@ -244,6 +255,59 @@ function App() {
}
};
+ // Centralized favorites loading (single API call, cached in state)
+ const loadFavorites = async () => {
+ if (!user) {
+ setFavorites([]);
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/favorites', {
+ credentials: 'include'
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ setFavorites(data.favorites);
+ console.log('Loaded favorites:', data.favorites.length);
+ }
+ } catch (error) {
+ console.error('Failed to load favorites:', error);
+ setFavorites([]);
+ }
+ };
+
+ // Helper functions to filter favorites by type
+ const getBookFavorites = (version: string) => {
+ return new Set(
+ favorites
+ .filter(fav => !fav.chapter && fav.version === version)
+ .map(fav => fav.book)
+ );
+ };
+
+ const getChapterFavorites = (book: string, version: string) => {
+ return new Set(
+ favorites
+ .filter(fav => fav.book === book && fav.chapter && fav.version === version && !fav.verse_start)
+ .map(fav => fav.chapter)
+ );
+ };
+
+ const getVerseFavorites = (book: string, chapter: string, version: string) => {
+ return new Set(
+ favorites
+ .filter(fav => fav.book === book && fav.chapter === chapter && fav.verse_start && fav.version === version)
+ .map(fav => fav.verse_end ? `${fav.verse_start}-${fav.verse_end}` : fav.verse_start.toString())
+ );
+ };
+
+ // Unified favorite change handler that reloads favorites
+ const handleFavoriteChange = async () => {
+ await loadFavorites(); // Reload favorites after any change
+ };
+
// Get current navigation info from URL
const getCurrentNavInfo = () => {
const pathParts = location.pathname.split('/').filter(Boolean);
@@ -286,11 +350,6 @@ function App() {
navigate('/');
};
- const handleFavoriteChange = () => {
- // This will trigger a re-render of the FavoritesMenu
- setUser((prev: any) => ({ ...prev }));
- };
-
return (
setShowSearch(true)}
+ favorites={versionId ? getBookFavorites(versionId) : new Set()}
/>
);
};
@@ -318,11 +378,6 @@ function App() {
navigate(`/version/${versionId}`);
};
- const handleFavoriteChange = () => {
- // This will trigger a re-render of the FavoritesMenu
- setUser((prev: any) => ({ ...prev }));
- };
-
if (!bookName || !actualBookName || !books.includes(actualBookName)) {
return Book not found
;
}
@@ -337,6 +392,7 @@ function App() {
onFavoriteChange={handleFavoriteChange}
version={versionId}
onSearchClick={() => setShowSearch(true)}
+ favorites={versionId ? getChapterFavorites(actualBookName, versionId) : new Set()}
/>
);
};
@@ -350,11 +406,6 @@ function App() {
navigate(`/version/${versionId}/book/${bookName}`);
};
- const handleFavoriteChange = () => {
- // This will trigger a re-render of the FavoritesMenu
- setUser((prev: any) => ({ ...prev }));
- };
-
if (!bookName || !chapterNumber || !actualBookName) {
return Chapter not found
;
}
@@ -369,6 +420,7 @@ function App() {
onFavoriteChange={handleFavoriteChange}
version={selectedVersion}
onSearchClick={() => setShowSearch(true)}
+ favorites={getVerseFavorites(actualBookName, chapterNumber, selectedVersion)}
/>
);
};
@@ -494,7 +546,7 @@ function App() {
formatBookName={formatBookName}
getBookUrlName={getBookUrlName}
setSelectedVersion={setSelectedVersion}
- onFavoriteChange={() => setUser((prev: any) => ({ ...prev }))}
+ onFavoriteChange={handleFavoriteChange}
/>
diff --git a/frontend/src/components/BibleReader.tsx b/frontend/src/components/BibleReader.tsx
index 3486bb9c..380de94e 100644
--- a/frontend/src/components/BibleReader.tsx
+++ b/frontend/src/components/BibleReader.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, memo } from 'react';
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Star, Search } from 'lucide-react';
import { getChapter, getBook } from '../services/api';
@@ -11,13 +11,23 @@ interface BibleReaderProps {
onFavoriteChange?: () => void;
version?: string;
onSearchClick?: () => void;
+ favorites?: Set; // Favorites passed from parent (centralized state)
}
-const BibleReader: React.FC = ({ book, chapter, onBack, formatBookName, user, onFavoriteChange, version = 'esv', onSearchClick }) => {
+const BibleReader: React.FC = memo(({
+ book,
+ chapter,
+ onBack,
+ formatBookName,
+ user,
+ onFavoriteChange,
+ version = 'esv',
+ onSearchClick,
+ favorites = new Set() // Default to empty set if not provided
+}) => {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [chapters, setChapters] = useState([]);
- const [favorites, setFavorites] = useState>(new Set());
const [fontSize, setFontSize] = useState<'small' | 'medium' | 'large'>(() => {
// Load font size preference from localStorage
const saved = localStorage.getItem('fontSize');
@@ -39,9 +49,9 @@ const BibleReader: React.FC = ({ book, chapter, onBack, format
if (verseElement) {
// Small delay to ensure content is rendered
setTimeout(() => {
- verseElement.scrollIntoView({
- behavior: 'smooth',
- block: 'center'
+ verseElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
});
}, 100);
}
@@ -49,36 +59,6 @@ const BibleReader: React.FC = ({ book, chapter, onBack, format
}
}, [loading, content]);
- // Load favorites when user is available and reload when version changes
- useEffect(() => {
- if (user) {
- loadFavorites();
- }
- }, [user, book, chapter, version]);
-
- const loadFavorites = async () => {
- if (!user) return;
-
- try {
- const response = await fetch('/api/favorites', {
- credentials: 'include'
- });
-
- if (response.ok) {
- const data = await response.json();
- const favoriteStrings: string[] = data.favorites
- .filter((fav: any) => fav.book === book && fav.chapter === chapter && fav.verse_start && fav.version === version) // Only verse-level favorites for this chapter and version
- .map((fav: any) => fav.verse_end ? `${fav.verse_start}-${fav.verse_end}` : fav.verse_start.toString());
-
- const verseFavorites = new Set(favoriteStrings);
- setFavorites(verseFavorites);
- console.log('Loaded verse favorites for version:', version, favoriteStrings);
- }
- } catch (error) {
- console.error('Failed to load favorites:', error);
- }
- };
-
const toggleFavorite = async (verseNumber: string) => {
if (!user) return;
@@ -429,6 +409,6 @@ const BibleReader: React.FC = ({ book, chapter, onBack, format
);
-};
+});
export default BibleReader;
diff --git a/frontend/src/components/BookSelector.tsx b/frontend/src/components/BookSelector.tsx
index 537040fd..6c017031 100644
--- a/frontend/src/components/BookSelector.tsx
+++ b/frontend/src/components/BookSelector.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, memo } from 'react';
import { BookOpen, Star, ChevronLeft, Search } from 'lucide-react';
interface BookSelectorProps {
@@ -10,53 +10,20 @@ interface BookSelectorProps {
version?: string;
onBack?: () => void;
onSearchClick?: () => void;
+ favorites?: Set; // Favorites passed from parent (centralized state)
}
-const BookSelector: React.FC = ({ books, onBookSelect, formatBookName, user, onFavoriteChange, version = 'esv', onBack, onSearchClick }) => {
- const [favorites, setFavorites] = useState>(new Set());
- const [loading, setLoading] = useState(false);
-
- // Load favorites when user is available or when component mounts
- useEffect(() => {
- if (user) {
- loadFavorites();
- } else {
- setFavorites(new Set()); // Clear favorites when no user
- }
- }, [user]);
-
- // Also reload favorites when books or version change (page refresh/version switch)
- useEffect(() => {
- if (user && books.length > 0) {
- loadFavorites();
- }
- }, [books, user, version]);
-
- const loadFavorites = async () => {
- if (!user) return;
-
- setLoading(true);
- try {
- const response = await fetch('/api/favorites', {
- credentials: 'include'
- });
-
- if (response.ok) {
- const data = await response.json();
- const favoriteBooks: string[] = data.favorites
- .filter((fav: any) => !fav.chapter && fav.version === version) // Only book-level favorites for current version
- .map((fav: any) => fav.book);
-
- const bookFavorites = new Set(favoriteBooks);
- setFavorites(bookFavorites);
- console.log('Loaded book favorites for version:', version, favoriteBooks);
- }
- } catch (error) {
- console.error('Failed to load favorites:', error);
- } finally {
- setLoading(false);
- }
- };
+const BookSelector: React.FC = memo(({
+ books,
+ onBookSelect,
+ formatBookName,
+ user,
+ onFavoriteChange,
+ version = 'esv',
+ onBack,
+ onSearchClick,
+ favorites = new Set() // Default to empty set if not provided
+}) => {
const toggleFavorite = async (book: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent book selection when clicking star
@@ -211,6 +178,6 @@ const BookSelector: React.FC = ({ books, onBookSelect, format
);
-};
+});
export default BookSelector;
diff --git a/frontend/src/components/ChapterSelector.tsx b/frontend/src/components/ChapterSelector.tsx
index e099e786..83cc1acc 100644
--- a/frontend/src/components/ChapterSelector.tsx
+++ b/frontend/src/components/ChapterSelector.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, memo } from 'react';
import { ArrowLeft, FileText, Star, ChevronRight, Search } from 'lucide-react';
import { getBook } from '../services/api';
@@ -11,47 +11,27 @@ interface ChapterSelectorProps {
onFavoriteChange?: () => void;
version?: string;
onSearchClick?: () => void;
+ favorites?: Set; // Favorites passed from parent (centralized state)
}
-const ChapterSelector: React.FC = ({ book, onChapterSelect, onBack, formatBookName, user, onFavoriteChange, version = 'esv', onSearchClick }) => {
+const ChapterSelector: React.FC = memo(({
+ book,
+ onChapterSelect,
+ onBack,
+ formatBookName,
+ user,
+ onFavoriteChange,
+ version = 'esv',
+ onSearchClick,
+ favorites = new Set() // Default to empty set if not provided
+}) => {
const [chapters, setChapters] = useState([]);
const [loading, setLoading] = useState(true);
- const [favorites, setFavorites] = useState>(new Set());
useEffect(() => {
loadChapters();
}, [book]);
- // Load favorites when user is available
- useEffect(() => {
- if (user) {
- loadFavorites();
- }
- }, [user, book, version]);
-
- const loadFavorites = async () => {
- if (!user) return;
-
- try {
- const response = await fetch('/api/favorites', {
- credentials: 'include'
- });
-
- if (response.ok) {
- const data = await response.json();
- const favoriteChapters: string[] = data.favorites
- .filter((fav: any) => fav.book === book && fav.chapter && fav.version === version && !fav.verse_start) // Only chapter-level favorites for this book and version
- .map((fav: any) => fav.chapter);
-
- const chapterFavorites = new Set(favoriteChapters);
- setFavorites(chapterFavorites);
- console.log('Loaded chapter favorites for version:', version, favoriteChapters);
- }
- } catch (error) {
- console.error('Failed to load favorites:', error);
- }
- };
-
const toggleFavorite = async (chapter: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent chapter selection when clicking star
@@ -231,6 +211,6 @@ const ChapterSelector: React.FC = ({ book, onChapterSelect
);
-};
+});
export default ChapterSelector;