diff --git a/backend/src/index.js b/backend/src/index.js index 74515a8a..c94f8f16 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -5,6 +5,7 @@ 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; @@ -34,6 +35,9 @@ 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 { @@ -131,6 +135,53 @@ app.get('/books/:book/:chapter', async (req, res) => { } }); +// 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) => { diff --git a/backend/src/search.js b/backend/src/search.js new file mode 100644 index 00000000..ff663169 --- /dev/null +++ b/backend/src/search.js @@ -0,0 +1,255 @@ +const fs = require('fs').promises; +const path = require('path'); + +class BibleSearchEngine { + constructor(bibleDataDir) { + this.bibleDataDir = bibleDataDir; + this.searchIndex = new Map(); + this.isIndexed = false; + } + + // Parse verses from markdown content + parseVersesFromMarkdown(content, book, chapter) { + const verses = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Match verse patterns like "1 In the beginning..." or "**1** In the beginning..." + const verseMatch = line.match(/^(\*\*)?(\d+)(\*\*)?\s+(.+)$/); + + if (verseMatch) { + const verseNumber = parseInt(verseMatch[2]); + const verseText = verseMatch[4]; + + verses.push({ + book, + chapter, + verse: verseNumber, + text: verseText, + fullText: line + }); + } + } + + return verses; + } + + // Get context verses around a specific verse + getContextVerses(allVerses, targetVerse, contextSize = 2) { + const targetIndex = allVerses.findIndex(v => v.verse === targetVerse); + if (targetIndex === -1) return []; + + const start = Math.max(0, targetIndex - contextSize); + const end = Math.min(allVerses.length, targetIndex + contextSize + 1); + + return allVerses.slice(start, end); + } + + // Calculate relevance score for search results + 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+/); + 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 + } + } + } + + // Boost score for shorter verses (more focused results) + if (text.length < 100) score += 10; + + return score; + } + + // Build search index from all bible files + async buildSearchIndex() { + console.log('Building search index...'); + this.searchIndex.clear(); + + try { + const books = await this.getBooks(); + + for (const book of books) { + const bookPath = path.join(this.bibleDataDir, book); + const files = await fs.readdir(bookPath); + const chapterFiles = files.filter(file => file.endsWith('.md')).sort(); + + for (const chapterFile of chapterFiles) { + const chapterMatch = chapterFile.match(/Chapter_(\d+)\.md$/); + if (!chapterMatch) continue; + + const chapter = parseInt(chapterMatch[1]); + const filePath = path.join(bookPath, chapterFile); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + const verses = this.parseVersesFromMarkdown(content, book, chapter); + + // Index each verse + for (const verse of verses) { + const key = `${book}_${chapter}_${verse.verse}`; + this.searchIndex.set(key, verse); + } + } catch (error) { + console.error(`Error reading chapter file ${filePath}:`, error); + } + } + } + + this.isIndexed = true; + console.log(`Search index built with ${this.searchIndex.size} verses`); + } catch (error) { + console.error('Error building search index:', error); + throw error; + } + } + + // Get all books + async getBooks() { + try { + const items = await fs.readdir(this.bibleDataDir); + const bookDirs = []; + + for (const item of items) { + const itemPath = path.join(this.bibleDataDir, item); + const stat = await fs.stat(itemPath); + + if (stat.isDirectory()) { + try { + const files = await fs.readdir(itemPath); + if (files.some(file => file.endsWith('.md'))) { + bookDirs.push(item); + } + } catch (error) { + continue; + } + } + } + + return bookDirs; + } catch (error) { + throw new Error('Failed to read bible data directory'); + } + } + + // Search function + async search(query, options = {}) { + const { + bookFilter = null, + limit = 50, + includeContext = true, + contextSize = 2 + } = options; + + // Build index if not already built + if (!this.isIndexed) { + await this.buildSearchIndex(); + } + + if (!query || query.trim().length < 2) { + return []; + } + + const results = []; + const lowerQuery = query.toLowerCase().trim(); + + // Search through indexed verses + for (const [key, verse] of this.searchIndex) { + // Apply book filter if specified + if (bookFilter && verse.book !== bookFilter) { + continue; + } + + // Check if verse text contains the query + if (verse.text.toLowerCase().includes(lowerQuery)) { + const relevance = this.calculateRelevance(verse.text, query); + + let context = []; + if (includeContext) { + // Get all verses for this chapter to provide context + const chapterVerses = Array.from(this.searchIndex.values()) + .filter(v => v.book === verse.book && v.chapter === verse.chapter) + .sort((a, b) => a.verse - b.verse); + + context = this.getContextVerses(chapterVerses, verse.verse, contextSize); + } + + results.push({ + book: verse.book, + chapter: verse.chapter, + verse: verse.verse, + text: verse.text, + fullText: verse.fullText, + context, + relevance, + highlight: this.highlightText(verse.text, query) + }); + } + } + + // Sort by relevance and limit results + return results + .sort((a, b) => b.relevance - a.relevance) + .slice(0, limit); + } + + // Highlight search terms in text + 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; + } + + // Get search suggestions (for autocomplete) + async getSearchSuggestions(query, limit = 10) { + if (!this.isIndexed) { + await this.buildSearchIndex(); + } + + const suggestions = new Set(); + const lowerQuery = query.toLowerCase(); + + for (const verse of this.searchIndex.values()) { + const words = verse.text.toLowerCase().split(/\s+/); + + for (const word of words) { + if (word.startsWith(lowerQuery) && word.length > lowerQuery.length) { + suggestions.add(word); + if (suggestions.size >= limit) break; + } + } + + if (suggestions.size >= limit) break; + } + + return Array.from(suggestions).slice(0, limit); + } +} + +module.exports = BibleSearchEngine; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96812dec..e4fa3396 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react'; import { Routes, Route, useNavigate, useParams, useLocation } from 'react-router-dom'; -import { Book, ChevronRight, Moon, Sun, LogOut } from 'lucide-react'; +import { Book, ChevronRight, Moon, Sun, LogOut, Search } from 'lucide-react'; import BookSelector from './components/BookSelector'; import ChapterSelector from './components/ChapterSelector'; import BibleReader from './components/BibleReader'; import FavoritesMenu from './components/FavoritesMenu'; +import SearchComponent from './components/SearchComponent'; import { getBooks } from './services/api'; interface BookData { @@ -22,6 +23,7 @@ function App() { return saved ? JSON.parse(saved) : false; }); const [error, setError] = useState(''); + const [showSearch, setShowSearch] = useState(false); const location = useLocation(); const navigate = useNavigate(); @@ -379,8 +381,16 @@ function App() { )} - {/* User Authentication & Dark Mode */} + {/* Search, User Authentication & Dark Mode */}
+ {/* Search Button */} + {/* Authentication Button */} {authAvailable && (
@@ -439,12 +449,31 @@ function App() {
)} + {/* Search Modal */} + {showSearch && ( + setShowSearch(false)} + isModal={true} + /> + )} + {/* Main Content */}
} /> } /> } /> + + } />
diff --git a/frontend/src/components/SearchComponent.tsx b/frontend/src/components/SearchComponent.tsx new file mode 100644 index 00000000..e548a6e2 --- /dev/null +++ b/frontend/src/components/SearchComponent.tsx @@ -0,0 +1,252 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { Search, X, Book, ChevronRight, Loader2 } from 'lucide-react'; +import { searchBible, SearchResult, SearchResponse } from '../services/api'; +import { useNavigate } from 'react-router-dom'; + +interface SearchComponentProps { + formatBookName: (bookName: string) => string; + getBookUrlName: (bookName: string) => string; + books?: string[]; + onClose?: () => void; + isModal?: boolean; +} + +const SearchComponent: React.FC = ({ + formatBookName, + getBookUrlName, + books = [], + onClose, + isModal = false +}) => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [hasSearched, setHasSearched] = useState(false); + const [selectedBook, setSelectedBook] = useState(''); + const searchTimeoutRef = useRef(); + const navigate = useNavigate(); + + // Debounced search function + const debouncedSearch = useCallback(async (searchQuery: string, bookFilter?: string) => { + if (searchQuery.trim().length < 2) { + setResults([]); + setHasSearched(false); + return; + } + + setLoading(true); + setError(''); + + try { + const searchOptions = { + ...(bookFilter && { book: bookFilter }), + limit: 50, + context: true + }; + + const response: SearchResponse = await searchBible(searchQuery, searchOptions); + setResults(response.results); + setHasSearched(true); + } catch (err) { + console.error('Search error:', err); + setError('Search failed. Please try again.'); + setResults([]); + } finally { + setLoading(false); + } + }, []); + + // Handle search input changes + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = setTimeout(() => { + debouncedSearch(query, selectedBook); + }, 300); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [query, selectedBook, debouncedSearch]); + + // Handle result click + const handleResultClick = (result: SearchResult) => { + const urlBookName = getBookUrlName(formatBookName(result.book)); + navigate(`/book/${urlBookName}/chapter/${result.chapter}`); + if (onClose) onClose(); + }; + + // Highlight search terms in text + const highlightText = (text: string, searchQuery: string) => { + if (!searchQuery) return text; + + const words = searchQuery.toLowerCase().split(/\s+/); + let highlightedText = text; + + words.forEach(word => { + const regex = new RegExp(`(${word})`, 'gi'); + highlightedText = highlightedText.replace(regex, '$1'); + }); + + return highlightedText; + }; + + const containerClasses = isModal + ? "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" + : "w-full max-w-4xl mx-auto"; + + const contentClasses = isModal + ? "bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden" + : "bg-white dark:bg-gray-800 rounded-lg shadow-lg"; + + return ( +
+
+ {/* Header */} +
+
+ +

+ Search Bible +

+
+ {isModal && onClose && ( + + )} +
+ + {/* Search Input */} +
+
+
+ + setQuery(e.target.value)} + placeholder="Search for verses, words, or phrases..." + className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg + bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 + focus:ring-2 focus:ring-blue-500 focus:border-transparent" + autoFocus + /> + {loading && ( + + )} +
+ + +
+
+ + {/* Results */} +
+ {error && ( +
+ {error} +
+ )} + + {!loading && !error && hasSearched && results.length === 0 && ( +
+ +

No results found for "{query}"

+

Try different keywords or check your spelling

+
+ )} + + {!loading && !error && !hasSearched && query.length === 0 && ( +
+ +

Enter a search term to find verses

+

Search for words, phrases, or topics across the entire Bible

+
+ )} + + {results.length > 0 && ( +
+ {results.map((result, index) => ( +
handleResultClick(result)} + className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" + > + {/* Reference */} +
+ {formatBookName(result.book)} + + Chapter {result.chapter} + + Verse {result.verse} +
+ + {/* Verse Text */} +
+ + {/* Context */} + {result.context && result.context.length > 0 && ( +
+

Context:

+
+ {result.context.map((contextVerse, idx) => ( +
+ + {contextVerse.verse} + + + {contextVerse.verse === result.verse + ? + : contextVerse.text + } + +
+ ))} +
+
+ )} +
+ ))} +
+ )} +
+ + {/* Footer */} + {results.length > 0 && ( +
+ Found {results.length} result{results.length !== 1 ? 's' : ''} for "{query}" +
+ )} +
+
+ ); +}; + +export default SearchComponent; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a229cf01..60cdbfdc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -32,3 +32,56 @@ export const checkHealth = async (): Promise<{ status: string; message: string } const response = await api.get('/health'); return response.data; }; + +// Search interfaces +export interface SearchResult { + book: string; + chapter: number; + verse: number; + text: string; + fullText: string; + context: Array<{ + book: string; + chapter: number; + verse: number; + text: string; + }>; + relevance: number; + highlight: string; +} + +export interface SearchResponse { + query: string; + results: SearchResult[]; + total: number; + hasMore: boolean; +} + +export interface SearchOptions { + book?: string; + limit?: number; + context?: boolean; +} + +// Search API functions +export const searchBible = async (query: string, options: SearchOptions = {}): Promise => { + const params = new URLSearchParams({ + q: query, + ...(options.book && { book: options.book }), + ...(options.limit && { limit: options.limit.toString() }), + ...(options.context !== undefined && { context: options.context.toString() }) + }); + + const response = await api.get(`/api/search?${params}`); + return response.data; +}; + +export const getSearchSuggestions = async (query: string, limit: number = 10): Promise => { + const params = new URLSearchParams({ + q: query, + limit: limit.toString() + }); + + const response = await api.get(`/api/search/suggestions?${params}`); + return response.data.suggestions; +};