diff --git a/kjvstudy_org/routes/__init__.py b/kjvstudy_org/routes/__init__.py
new file mode 100644
index 0000000..930190f
--- /dev/null
+++ b/kjvstudy_org/routes/__init__.py
@@ -0,0 +1,6 @@
+"""Routes package for KJV Study."""
+from fastapi import APIRouter
+
+from .api import router as api_router
+
+__all__ = ['api_router']
diff --git a/kjvstudy_org/routes/api.py b/kjvstudy_org/routes/api.py
new file mode 100644
index 0000000..b3b4b9c
--- /dev/null
+++ b/kjvstudy_org/routes/api.py
@@ -0,0 +1,442 @@
+"""API routes for KJV Study - JSON endpoints for programmatic access."""
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query, Path
+from fastapi.responses import JSONResponse
+
+from ..kjv import bible
+from ..cross_references import get_cross_references
+from ..reading_plans import get_plan, get_plan_summary
+from ..topics import get_all_topics, get_topic
+from ..interlinear_loader import get_interlinear_data, has_interlinear_data
+from ..utils.books import normalize_book_name, OT_BOOKS
+from ..utils.search import perform_full_text_search
+from ..utils.helpers import get_daily_verse
+
+router = APIRouter(prefix="/api", tags=["API"])
+
+
+@router.get("/")
+def api_index():
+ """API index with links to documentation and available endpoints."""
+ return {
+ "name": "KJV Study API",
+ "version": "1.0.0",
+ "description": "RESTful API for accessing King James Bible verses and study resources",
+ "documentation": {
+ "swagger_ui": "/api/docs",
+ "redoc": "/api/redoc",
+ "openapi_json": "/api/openapi.json"
+ },
+ "endpoints": {
+ "health": "/api/health",
+ "search": "/api/search?q={query}",
+ "verse_of_the_day": "/api/verse-of-the-day",
+ "verse": "/api/verse/{book}/{chapter}/{verse}",
+ "verse_range": "/api/verse-range/{book}/{chapter}/{start}/{end}",
+ "interlinear": "/api/interlinear/{book}/{chapter}/{verse}",
+ "books": "/api/books",
+ "book": "/api/books/{book}",
+ "chapter": "/api/books/{book}/chapters/{chapter}",
+ "book_text": "/api/books/{book}/text",
+ "bible": "/api/bible",
+ "cross_references": "/api/cross-references/{book}/{chapter}/{verse}",
+ "topics": "/api/topics",
+ "topic": "/api/topics/{topic_name}",
+ "reading_plans": "/api/reading-plans",
+ "reading_plan": "/api/reading-plans/{plan_id}"
+ }
+ }
+
+
+@router.get("/health")
+def api_health_check():
+ """API health check endpoint for monitoring and status verification."""
+ return {
+ "status": "healthy",
+ "service": "KJV Study API",
+ "version": "1.0.0"
+ }
+
+
+@router.get("/search")
+def search_api(
+ q: str = Query(..., description="Search query", example="faith"),
+ limit: Optional[int] = Query(None, description="Max results", example=10)
+):
+ """JSON API endpoint for search."""
+ if not q or len(q.strip()) < 2:
+ return {"query": q, "results": [], "total": 0}
+
+ results = perform_full_text_search(q.strip(), limit)
+ is_direct_verse = False
+
+ # Check if this was a direct verse reference match
+ if results and len(results) == 1 and results[0].get("score") == 100.0:
+ is_direct_verse = True
+
+ return {
+ "query": q,
+ "results": results,
+ "total": len(results),
+ "is_direct_verse": is_direct_verse
+ }
+
+
+@router.get("/verse-of-the-day")
+def verse_of_the_day_api():
+ """API endpoint for verse of the day."""
+ return get_daily_verse()
+
+
+@router.get("/verse/{book}/{chapter}/{verse}")
+def api_get_verse(
+ book: str = Path(..., description="Book name", example="John"),
+ chapter: int = Path(..., description="Chapter number", example=3),
+ verse: int = Path(..., description="Verse number", example=16)
+):
+ """Get a single verse text."""
+ try:
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ verse_text = bible.get_verse_text(book, chapter, verse)
+ if not verse_text:
+ raise HTTPException(status_code=404, detail="Verse not found")
+
+ return JSONResponse({
+ "book": book,
+ "chapter": chapter,
+ "verse": verse,
+ "reference": f"{book} {chapter}:{verse}",
+ "text": verse_text
+ })
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/verse-range/{book}/{chapter}/{start}/{end}")
+def api_get_verse_range(
+ book: str = Path(..., description="Book name", example="Psalms"),
+ chapter: int = Path(..., description="Chapter number", example=23),
+ start: int = Path(..., description="Starting verse number", example=1),
+ end: int = Path(..., description="Ending verse number", example=6)
+):
+ """Get a range of verses."""
+ try:
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ verses = []
+ verse_texts = []
+
+ for verse_num in range(start, end + 1):
+ verse_text = bible.get_verse_text(book, chapter, verse_num)
+ if verse_text:
+ verses.append({
+ "verse": verse_num,
+ "text": verse_text
+ })
+ verse_texts.append(verse_text)
+
+ if not verses:
+ raise HTTPException(status_code=404, detail="Verse range not found")
+
+ return JSONResponse({
+ "book": book,
+ "chapter": chapter,
+ "start": start,
+ "end": end,
+ "reference": f"{book} {chapter}:{start}-{end}",
+ "verses": verses,
+ "text": " ".join(verse_texts)
+ })
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/interlinear/{book}/{chapter}/{verse}")
+def api_get_interlinear(
+ book: str = Path(..., description="Book name", example="John"),
+ chapter: int = Path(..., description="Chapter number", example=1),
+ verse: int = Path(..., description="Verse number", example=1)
+):
+ """Get interlinear (word-by-word) data for a verse."""
+ try:
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ verse_text = bible.get_verse_text(book, chapter, verse)
+ if not verse_text:
+ raise HTTPException(status_code=404, detail="Verse not found")
+
+ if not has_interlinear_data(book, chapter, verse):
+ return JSONResponse({
+ "book": book,
+ "chapter": chapter,
+ "verse": verse,
+ "reference": f"{book} {chapter}:{verse}",
+ "text": verse_text,
+ "interlinear_available": False,
+ "words": []
+ })
+
+ interlinear_words = get_interlinear_data(book, chapter, verse)
+
+ return JSONResponse({
+ "book": book,
+ "chapter": chapter,
+ "verse": verse,
+ "reference": f"{book} {chapter}:{verse}",
+ "text": verse_text,
+ "interlinear_available": True,
+ "words": interlinear_words
+ })
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/books")
+def api_get_books():
+ """Get list of all Bible books."""
+ books = list(bible.iter_books())
+
+ old_testament = []
+ new_testament = []
+
+ for book in books:
+ chapters = [ch for bk, ch in bible.iter_chapters() if bk == book]
+ book_info = {
+ "name": book,
+ "chapters": len(chapters),
+ "testament": "Old Testament" if book in OT_BOOKS else "New Testament"
+ }
+
+ if book in OT_BOOKS:
+ old_testament.append(book_info)
+ else:
+ new_testament.append(book_info)
+
+ return {
+ "total_books": len(books),
+ "old_testament": old_testament,
+ "new_testament": new_testament
+ }
+
+
+@router.get("/books/{book}")
+def api_get_book(book: str = Path(..., description="Book name", example="Genesis")):
+ """Get details about a specific book."""
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ chapters = [ch for bk, ch in bible.iter_chapters() if bk == book]
+ if not chapters:
+ raise HTTPException(status_code=404, detail="Book not found")
+
+ chapter_details = []
+ for chapter in chapters:
+ verses = [v for v in bible.iter_verses() if v.book == book and v.chapter == chapter]
+ chapter_details.append({
+ "chapter": chapter,
+ "verses": len(verses)
+ })
+
+ return {
+ "name": book,
+ "total_chapters": len(chapters),
+ "chapters": chapter_details
+ }
+
+
+@router.get("/books/{book}/chapters/{chapter}")
+def api_get_chapter(
+ book: str = Path(..., description="Book name", example="Romans"),
+ chapter: int = Path(..., description="Chapter number", example=8)
+):
+ """Get all verses in a chapter."""
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ verses = [v for v in bible.iter_verses() if v.book == book and v.chapter == chapter]
+ if not verses:
+ raise HTTPException(status_code=404, detail="Chapter not found")
+
+ verse_list = []
+ for v in verses:
+ verse_list.append({
+ "verse": v.verse,
+ "text": v.text
+ })
+
+ return {
+ "book": book,
+ "chapter": chapter,
+ "total_verses": len(verses),
+ "verses": verse_list
+ }
+
+
+@router.get("/books/{book}/text")
+def api_get_book_text(book: str = Path(..., description="Book name", example="Philemon")):
+ """Get all text content of a book."""
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ verses = [v for v in bible.iter_verses() if v.book == book]
+ if not verses:
+ raise HTTPException(status_code=404, detail="Book not found")
+
+ chapters = {}
+ for v in verses:
+ if v.chapter not in chapters:
+ chapters[v.chapter] = []
+ chapters[v.chapter].append({
+ "verse": v.verse,
+ "text": v.text
+ })
+
+ chapter_list = []
+ for chapter_num in sorted(chapters.keys()):
+ chapter_list.append({
+ "chapter": chapter_num,
+ "verses": chapters[chapter_num]
+ })
+
+ return {
+ "book": book,
+ "total_chapters": len(chapters),
+ "total_verses": len(verses),
+ "chapters": chapter_list
+ }
+
+
+@router.get("/bible")
+def api_get_bible():
+ """Get the entire Bible text."""
+ books_data = {}
+ for v in bible.iter_verses():
+ if v.book not in books_data:
+ books_data[v.book] = {}
+ if v.chapter not in books_data[v.book]:
+ books_data[v.book][v.chapter] = []
+ books_data[v.book][v.chapter].append({
+ "verse": v.verse,
+ "text": v.text
+ })
+
+ books_list = []
+ for book_name in books_data:
+ chapter_list = []
+ for chapter_num in sorted(books_data[book_name].keys()):
+ chapter_list.append({
+ "chapter": chapter_num,
+ "verses": books_data[book_name][chapter_num]
+ })
+
+ books_list.append({
+ "book": book_name,
+ "chapters": chapter_list
+ })
+
+ total_verses = sum(len(books_data[book][ch]) for book in books_data for ch in books_data[book])
+
+ return {
+ "total_books": len(books_data),
+ "total_verses": total_verses,
+ "books": books_list
+ }
+
+
+@router.get("/cross-references/{book}/{chapter}/{verse}")
+def api_get_cross_references(
+ book: str = Path(..., description="Book name", example="John"),
+ chapter: int = Path(..., description="Chapter number", example=3),
+ verse: int = Path(..., description="Verse number", example=16)
+):
+ """Get cross-references for a verse."""
+ canonical_name = normalize_book_name(book)
+ if canonical_name:
+ book = canonical_name
+
+ verse_text = bible.get_verse_text(book, chapter, verse)
+ if not verse_text:
+ raise HTTPException(status_code=404, detail="Verse not found")
+
+ cross_refs = get_cross_references(book, chapter, verse)
+
+ return {
+ "book": book,
+ "chapter": chapter,
+ "verse": verse,
+ "reference": f"{book} {chapter}:{verse}",
+ "cross_references": cross_refs
+ }
+
+
+@router.get("/topics")
+def api_get_topics():
+ """Get list of all topics."""
+ topics = get_all_topics()
+
+ topic_list = []
+ for topic_name, topic_data in topics.items():
+ topic_list.append({
+ "name": topic_name,
+ "slug": topic_name,
+ "description": topic_data.get("description", ""),
+ "subtopics": list(topic_data.get("subtopics", {}).keys())
+ })
+
+ return {
+ "total_topics": len(topics),
+ "topics": topic_list
+ }
+
+
+@router.get("/topics/{topic_name}")
+def api_get_topic(topic_name: str = Path(..., description="Topic name", example="faith")):
+ """Get details about a specific topic."""
+ topic = get_topic(topic_name)
+ if not topic:
+ raise HTTPException(status_code=404, detail="Topic not found")
+
+ return {
+ "name": topic_name,
+ "description": topic.get("description", ""),
+ "overview": topic.get("overview", ""),
+ "subtopics": topic.get("subtopics", {})
+ }
+
+
+@router.get("/reading-plans")
+def api_get_reading_plans():
+ """Get list of all reading plans."""
+ plans = get_plan_summary()
+
+ return {
+ "total_plans": len(plans),
+ "plans": plans
+ }
+
+
+@router.get("/reading-plans/{plan_id}")
+def api_get_reading_plan(plan_id: str = Path(..., description="Reading plan ID", example="chronological")):
+ """Get details about a specific reading plan."""
+ plan = get_plan(plan_id)
+ if not plan:
+ raise HTTPException(status_code=404, detail="Reading plan not found")
+
+ return plan
diff --git a/kjvstudy_org/server.py b/kjvstudy_org/server.py
index 1c6312b..e7eed60 100644
--- a/kjvstudy_org/server.py
+++ b/kjvstudy_org/server.py
@@ -23,6 +23,16 @@ from .reading_plans import get_plan, get_all_plans, get_plan_summary
from .topics import get_all_topics, get_topic, search_topics
from .interlinear_loader import get_interlinear_data, has_interlinear_data, get_all_interlinear_verses, preload_data
+# Import from modular packages
+from .routes import api_router
+from .utils.books import normalize_book_name, OT_BOOKS, NT_BOOKS
+from .utils.helpers import (
+ create_slug, get_verse_text, get_related_content,
+ get_chapter_popularity_score, get_chapter_popularity_explanation,
+ get_daily_verse, FEATURED_VERSES
+)
+from .utils.search import perform_full_text_search
+
try:
from ged4py import GedcomReader
except ImportError:
@@ -691,6 +701,9 @@ app = FastAPI(
openapi_url="/api/openapi.json"
)
+# Include the API router (routes defined in routes/api.py)
+app.include_router(api_router)
+
# Custom OpenAPI schema to only include /api routes
def custom_openapi():
@@ -841,68 +854,6 @@ def search_page(request: Request, q: str = Query(None, description="Search query
}
)
-@app.get("/api/")
-def api_index():
- """API index with links to documentation and available endpoints"""
- return {
- "name": "KJV Study API",
- "version": "1.0.0",
- "description": "RESTful API for accessing King James Bible verses and study resources",
- "documentation": {
- "swagger_ui": "/api/docs",
- "redoc": "/api/redoc",
- "openapi_json": "/api/openapi.json"
- },
- "endpoints": {
- "health": "/api/health",
- "search": "/api/search?q={query}",
- "verse_of_the_day": "/api/verse-of-the-day",
- "verse": "/api/verse/{book}/{chapter}/{verse}",
- "verse_range": "/api/verse-range/{book}/{chapter}/{start}/{end}",
- "interlinear": "/api/interlinear/{book}/{chapter}/{verse}",
- "books": "/api/books",
- "book": "/api/books/{book}",
- "chapter": "/api/books/{book}/chapters/{chapter}",
- "book_text": "/api/books/{book}/text",
- "bible": "/api/bible",
- "cross_references": "/api/cross-references/{book}/{chapter}/{verse}",
- "topics": "/api/topics",
- "topic": "/api/topics/{topic_name}",
- "reading_plans": "/api/reading-plans",
- "reading_plan": "/api/reading-plans/{plan_id}"
- }
- }
-
-@app.get("/api/health")
-def api_health_check():
- """API health check endpoint for monitoring and status verification"""
- return {
- "status": "healthy",
- "service": "KJV Study API",
- "version": "1.0.0"
- }
-
-@app.get("/api/search")
-def search_api(q: str = Query(..., description="Search query", example="faith"), limit: Optional[int] = Query(None, description="Max results", example=10)):
- """JSON API endpoint for search"""
- if not q or len(q.strip()) < 2:
- return {"query": q, "results": [], "total": 0}
-
- search_results = perform_full_text_search(q.strip(), limit)
- is_direct_verse = False
-
- # Check if this was a direct verse reference match
- if search_results and len(search_results) == 1 and search_results[0].get("score") == 100.0:
- is_direct_verse = True
-
- return {
- "query": q,
- "results": search_results,
- "total": len(search_results),
- "is_direct_verse": is_direct_verse
- }
-
-
@app.get("/concordance", response_class=HTMLResponse)
def concordance_page(request: Request, word: str = Query(None, description="Word to look up")):
"""Concordance page showing all occurrences of a word"""
@@ -1978,381 +1929,7 @@ def verse_of_the_day_page(request: Request):
}
)
-@app.get("/api/verse-of-the-day")
-def verse_of_the_day_api():
- """API endpoint for verse of the day"""
- return get_daily_verse()
-
-
-@app.get("/api/verse/{book}/{chapter}/{verse}")
-def api_get_verse(
- book: str = Path(..., description="Book name", example="John"),
- chapter: int = Path(..., description="Chapter number", example=3),
- verse: int = Path(..., description="Verse number", example=16)
-):
- """API endpoint to get a single verse text"""
- try:
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- verse_text = bible.get_verse_text(book, chapter, verse)
- if not verse_text:
- raise HTTPException(status_code=404, detail="Verse not found")
-
- return JSONResponse({
- "book": book,
- "chapter": chapter,
- "verse": verse,
- "reference": f"{book} {chapter}:{verse}",
- "text": verse_text
- })
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/api/verse-range/{book}/{chapter}/{start}/{end}")
-def api_get_verse_range(
- book: str = Path(..., description="Book name", example="Psalms"),
- chapter: int = Path(..., description="Chapter number", example=23),
- start: int = Path(..., description="Starting verse number", example=1),
- end: int = Path(..., description="Ending verse number", example=6)
-):
- """API endpoint to get a range of verses"""
- try:
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- verses = []
- verse_texts = []
-
- for verse_num in range(start, end + 1):
- verse_text = bible.get_verse_text(book, chapter, verse_num)
- if verse_text:
- verses.append({
- "verse": verse_num,
- "text": verse_text
- })
- verse_texts.append(verse_text)
-
- if not verses:
- raise HTTPException(status_code=404, detail="Verse range not found")
-
- return JSONResponse({
- "book": book,
- "chapter": chapter,
- "start": start,
- "end": end,
- "reference": f"{book} {chapter}:{start}-{end}",
- "verses": verses,
- "text": " ".join(verse_texts)
- })
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/api/interlinear/{book}/{chapter}/{verse}")
-def api_get_interlinear(
- book: str = Path(..., description="Book name", example="John"),
- chapter: int = Path(..., description="Chapter number", example=1),
- verse: int = Path(..., description="Verse number", example=1)
-):
- """API endpoint to get interlinear (word-by-word) data for a verse"""
- try:
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- # Check if verse exists
- verse_text = bible.get_verse_text(book, chapter, verse)
- if not verse_text:
- raise HTTPException(status_code=404, detail="Verse not found")
-
- # Check if interlinear data is available
- if not has_interlinear_data(book, chapter, verse):
- return JSONResponse({
- "book": book,
- "chapter": chapter,
- "verse": verse,
- "reference": f"{book} {chapter}:{verse}",
- "text": verse_text,
- "interlinear_available": False,
- "words": []
- })
-
- # Get interlinear data
- interlinear_words = get_interlinear_data(book, chapter, verse)
-
- return JSONResponse({
- "book": book,
- "chapter": chapter,
- "verse": verse,
- "reference": f"{book} {chapter}:{verse}",
- "text": verse_text,
- "interlinear_available": True,
- "words": interlinear_words
- })
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/api/books")
-def api_get_books():
- """API endpoint to get list of all Bible books"""
- books = list(bible.iter_books())
-
- # Categorize by testament
- old_testament = []
- new_testament = []
-
- ot_books = ['Genesis', 'Exodus', 'Leviticus', 'Numbers', 'Deuteronomy', 'Joshua',
- 'Judges', 'Ruth', '1 Samuel', '2 Samuel', '1 Kings', '2 Kings',
- '1 Chronicles', '2 Chronicles', 'Ezra', 'Nehemiah', 'Esther', 'Job',
- 'Psalms', 'Proverbs', 'Ecclesiastes', 'Song of Solomon', 'Isaiah',
- 'Jeremiah', 'Lamentations', 'Ezekiel', 'Daniel', 'Hosea', 'Joel',
- 'Amos', 'Obadiah', 'Jonah', 'Micah', 'Nahum', 'Habakkuk', 'Zephaniah',
- 'Haggai', 'Zechariah', 'Malachi']
-
- for book in books:
- chapters = [ch for bk, ch in bible.iter_chapters() if bk == book]
- book_info = {
- "name": book,
- "chapters": len(chapters),
- "testament": "Old Testament" if book in ot_books else "New Testament"
- }
-
- if book in ot_books:
- old_testament.append(book_info)
- else:
- new_testament.append(book_info)
-
- return {
- "total_books": len(books),
- "old_testament": old_testament,
- "new_testament": new_testament
- }
-
-
-@app.get("/api/books/{book}")
-def api_get_book(book: str = Path(..., description="Book name", example="Genesis")):
- """API endpoint to get details about a specific book"""
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- chapters = [ch for bk, ch in bible.iter_chapters() if bk == book]
- if not chapters:
- raise HTTPException(status_code=404, detail="Book not found")
-
- # Get verse count for each chapter
- chapter_details = []
- for chapter in chapters:
- verses = [v for v in bible.iter_verses() if v.book == book and v.chapter == chapter]
- chapter_details.append({
- "chapter": chapter,
- "verses": len(verses)
- })
-
- return {
- "name": book,
- "total_chapters": len(chapters),
- "chapters": chapter_details
- }
-
-
-@app.get("/api/books/{book}/chapters/{chapter}")
-def api_get_chapter(
- book: str = Path(..., description="Book name", example="Romans"),
- chapter: int = Path(..., description="Chapter number", example=8)
-):
- """API endpoint to get all verses in a chapter"""
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- verses = [v for v in bible.iter_verses() if v.book == book and v.chapter == chapter]
- if not verses:
- raise HTTPException(status_code=404, detail="Chapter not found")
-
- verse_list = []
- for v in verses:
- verse_list.append({
- "verse": v.verse,
- "text": v.text
- })
-
- return {
- "book": book,
- "chapter": chapter,
- "total_verses": len(verses),
- "verses": verse_list
- }
-
-
-@app.get("/api/books/{book}/text")
-def api_get_book_text(book: str = Path(..., description="Book name", example="Philemon")):
- """API endpoint to get all text content of a book"""
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- verses = [v for v in bible.iter_verses() if v.book == book]
- if not verses:
- raise HTTPException(status_code=404, detail="Book not found")
-
- # Group verses by chapter
- chapters = {}
- for v in verses:
- if v.chapter not in chapters:
- chapters[v.chapter] = []
- chapters[v.chapter].append({
- "verse": v.verse,
- "text": v.text
- })
-
- chapter_list = []
- for chapter_num in sorted(chapters.keys()):
- chapter_list.append({
- "chapter": chapter_num,
- "verses": chapters[chapter_num]
- })
-
- return {
- "book": book,
- "total_chapters": len(chapters),
- "total_verses": len(verses),
- "chapters": chapter_list
- }
-
-
-@app.get("/api/bible")
-def api_get_bible():
- """API endpoint to get the entire Bible text"""
- # Group all verses by book and chapter
- books_data = {}
- for v in bible.iter_verses():
- if v.book not in books_data:
- books_data[v.book] = {}
- if v.chapter not in books_data[v.book]:
- books_data[v.book][v.chapter] = []
- books_data[v.book][v.chapter].append({
- "verse": v.verse,
- "text": v.text
- })
-
- # Structure the data
- books_list = []
- for book_name in books_data:
- chapter_list = []
- for chapter_num in sorted(books_data[book_name].keys()):
- chapter_list.append({
- "chapter": chapter_num,
- "verses": books_data[book_name][chapter_num]
- })
-
- books_list.append({
- "book": book_name,
- "chapters": chapter_list
- })
-
- total_verses = sum(len(books_data[book][ch]) for book in books_data for ch in books_data[book])
-
- return {
- "total_books": len(books_data),
- "total_verses": total_verses,
- "books": books_list
- }
-
-
-@app.get("/api/cross-references/{book}/{chapter}/{verse}")
-def api_get_cross_references(
- book: str = Path(..., description="Book name", example="John"),
- chapter: int = Path(..., description="Chapter number", example=3),
- verse: int = Path(..., description="Verse number", example=16)
-):
- """API endpoint to get cross-references for a verse"""
- # Normalize book name variations
- canonical_name = normalize_book_name(book)
- if canonical_name:
- book = canonical_name
-
- # Check if verse exists
- verse_text = bible.get_verse_text(book, chapter, verse)
- if not verse_text:
- raise HTTPException(status_code=404, detail="Verse not found")
-
- cross_refs = get_cross_references(book, chapter, verse)
-
- return {
- "book": book,
- "chapter": chapter,
- "verse": verse,
- "reference": f"{book} {chapter}:{verse}",
- "cross_references": cross_refs
- }
-
-
-@app.get("/api/topics")
-def api_get_topics():
- """API endpoint to get list of all topics"""
- topics = get_all_topics()
-
- topic_list = []
- for topic_name, topic_data in topics.items():
- topic_list.append({
- "name": topic_name,
- "slug": topic_name,
- "description": topic_data.get("description", ""),
- "subtopics": list(topic_data.get("subtopics", {}).keys())
- })
-
- return {
- "total_topics": len(topics),
- "topics": topic_list
- }
-
-
-@app.get("/api/topics/{topic_name}")
-def api_get_topic(topic_name: str = Path(..., description="Topic name", example="faith")):
- """API endpoint to get details about a specific topic"""
- topic = get_topic(topic_name)
- if not topic:
- raise HTTPException(status_code=404, detail="Topic not found")
-
- return {
- "name": topic_name,
- "description": topic.get("description", ""),
- "overview": topic.get("overview", ""),
- "subtopics": topic.get("subtopics", {})
- }
-
-
-@app.get("/api/reading-plans")
-def api_get_reading_plans():
- """API endpoint to get list of all reading plans"""
- plans = get_plan_summary()
-
- return {
- "total_plans": len(plans),
- "plans": plans
- }
-
-
-@app.get("/api/reading-plans/{plan_id}")
-def api_get_reading_plan(plan_id: str = Path(..., description="Reading plan ID", example="chronological")):
- """API endpoint to get details about a specific reading plan"""
- plan = get_plan(plan_id)
- if not plan:
- raise HTTPException(status_code=404, detail="Reading plan not found")
-
- return plan
+# Note: API routes have been moved to routes/api.py and are included via app.include_router(api_router)
@app.get("/biblical-maps", response_class=HTMLResponse)
diff --git a/kjvstudy_org/utils/__init__.py b/kjvstudy_org/utils/__init__.py
new file mode 100644
index 0000000..a496492
--- /dev/null
+++ b/kjvstudy_org/utils/__init__.py
@@ -0,0 +1,8 @@
+# Utility modules for KJV Study
+# Note: Import individual modules to avoid circular imports
+
+__all__ = [
+ 'books',
+ 'search',
+ 'helpers',
+]
diff --git a/kjvstudy_org/utils/books.py b/kjvstudy_org/utils/books.py
new file mode 100644
index 0000000..58bba2d
--- /dev/null
+++ b/kjvstudy_org/utils/books.py
@@ -0,0 +1,250 @@
+"""Book name normalization and categorization utilities."""
+from typing import Optional
+
+# Old Testament books
+OT_BOOKS = [
+ 'Genesis', 'Exodus', 'Leviticus', 'Numbers', 'Deuteronomy',
+ 'Joshua', 'Judges', 'Ruth', '1 Samuel', '2 Samuel',
+ '1 Kings', '2 Kings', '1 Chronicles', '2 Chronicles',
+ 'Ezra', 'Nehemiah', 'Esther', 'Job', 'Psalms', 'Proverbs',
+ 'Ecclesiastes', 'Song of Solomon', 'Isaiah', 'Jeremiah',
+ 'Lamentations', 'Ezekiel', 'Daniel', 'Hosea', 'Joel', 'Amos',
+ 'Obadiah', 'Jonah', 'Micah', 'Nahum', 'Habakkuk', 'Zephaniah',
+ 'Haggai', 'Zechariah', 'Malachi'
+]
+
+# New Testament books
+NT_BOOKS = [
+ 'Matthew', 'Mark', 'Luke', 'John', 'Acts',
+ 'Romans', '1 Corinthians', '2 Corinthians', 'Galatians', 'Ephesians',
+ 'Philippians', 'Colossians', '1 Thessalonians', '2 Thessalonians',
+ '1 Timothy', '2 Timothy', 'Titus', 'Philemon',
+ 'Hebrews', 'James', '1 Peter', '2 Peter',
+ '1 John', '2 John', '3 John', 'Jude', 'Revelation'
+]
+
+# Comprehensive book name abbreviations and variations
+BOOK_ABBREVIATIONS = {
+ # Psalm/Psalms
+ "Psalm": "Psalms",
+
+ # Roman numerals to Arabic numerals
+ "I Samuel": "1 Samuel",
+ "II Samuel": "2 Samuel",
+ "I Kings": "1 Kings",
+ "II Kings": "2 Kings",
+ "I Chronicles": "1 Chronicles",
+ "II Chronicles": "2 Chronicles",
+ "I Corinthians": "1 Corinthians",
+ "II Corinthians": "2 Corinthians",
+ "I Thessalonians": "1 Thessalonians",
+ "II Thessalonians": "2 Thessalonians",
+ "I Timothy": "1 Timothy",
+ "II Timothy": "2 Timothy",
+ "I Peter": "1 Peter",
+ "II Peter": "2 Peter",
+ "I John": "1 John",
+ "II John": "2 John",
+ "III John": "3 John",
+
+ # Full word numbers to Arabic numerals
+ "First Samuel": "1 Samuel",
+ "Second Samuel": "2 Samuel",
+ "First Kings": "1 Kings",
+ "Second Kings": "2 Kings",
+ "First Chronicles": "1 Chronicles",
+ "Second Chronicles": "2 Chronicles",
+ "First Corinthians": "1 Corinthians",
+ "Second Corinthians": "2 Corinthians",
+ "First Thessalonians": "1 Thessalonians",
+ "Second Thessalonians": "2 Thessalonians",
+ "First Timothy": "1 Timothy",
+ "Second Timothy": "2 Timothy",
+ "First Peter": "1 Peter",
+ "Second Peter": "2 Peter",
+ "First John": "1 John",
+ "Second John": "2 John",
+ "Third John": "3 John",
+
+ # Alternative names
+ "Song of Songs": "Song of Solomon",
+ "Canticles": "Song of Solomon",
+
+ # Common abbreviations - Old Testament
+ "Gen": "Genesis",
+ "Ge": "Genesis",
+ "Exo": "Exodus",
+ "Ex": "Exodus",
+ "Lev": "Leviticus",
+ "Le": "Leviticus",
+ "Num": "Numbers",
+ "Nu": "Numbers",
+ "Deut": "Deuteronomy",
+ "Dt": "Deuteronomy",
+ "Josh": "Joshua",
+ "Jos": "Joshua",
+ "Judg": "Judges",
+ "Jdg": "Judges",
+ "Ru": "Ruth",
+ "1Sam": "1 Samuel",
+ "1 Sam": "1 Samuel",
+ "1S": "1 Samuel",
+ "2Sam": "2 Samuel",
+ "2 Sam": "2 Samuel",
+ "2S": "2 Samuel",
+ "1Ki": "1 Kings",
+ "1 Ki": "1 Kings",
+ "1K": "1 Kings",
+ "2Ki": "2 Kings",
+ "2 Ki": "2 Kings",
+ "2K": "2 Kings",
+ "1Chr": "1 Chronicles",
+ "1 Chr": "1 Chronicles",
+ "1Ch": "1 Chronicles",
+ "2Chr": "2 Chronicles",
+ "2 Chr": "2 Chronicles",
+ "2Ch": "2 Chronicles",
+ "Ezr": "Ezra",
+ "Neh": "Nehemiah",
+ "Ne": "Nehemiah",
+ "Est": "Esther",
+ "Ps": "Psalms",
+ "Psa": "Psalms",
+ "Prov": "Proverbs",
+ "Pr": "Proverbs",
+ "Eccl": "Ecclesiastes",
+ "Ec": "Ecclesiastes",
+ "Song": "Song of Solomon",
+ "Sos": "Song of Solomon",
+ "SS": "Song of Solomon",
+ "Isa": "Isaiah",
+ "Is": "Isaiah",
+ "Jer": "Jeremiah",
+ "Je": "Jeremiah",
+ "Lam": "Lamentations",
+ "La": "Lamentations",
+ "Ezek": "Ezekiel",
+ "Eze": "Ezekiel",
+ "Ezk": "Ezekiel",
+ "Dan": "Daniel",
+ "Da": "Daniel",
+ "Hos": "Hosea",
+ "Ho": "Hosea",
+ "Joe": "Joel",
+ "Jl": "Joel",
+ "Am": "Amos",
+ "Ob": "Obadiah",
+ "Jon": "Jonah",
+ "Mic": "Micah",
+ "Mi": "Micah",
+ "Nah": "Nahum",
+ "Na": "Nahum",
+ "Hab": "Habakkuk",
+ "Hb": "Habakkuk",
+ "Zep": "Zephaniah",
+ "Zph": "Zephaniah",
+ "Hag": "Haggai",
+ "Hg": "Haggai",
+ "Zech": "Zechariah",
+ "Zec": "Zechariah",
+ "Zch": "Zechariah",
+ "Mal": "Malachi",
+
+ # Common abbreviations - New Testament
+ "Mat": "Matthew",
+ "Mt": "Matthew",
+ "Mar": "Mark",
+ "Mk": "Mark",
+ "Mrk": "Mark",
+ "Luk": "Luke",
+ "Lk": "Luke",
+ "Joh": "John",
+ "Jn": "John",
+ "Act": "Acts",
+ "Ac": "Acts",
+ "Rom": "Romans",
+ "Ro": "Romans",
+ "1Cor": "1 Corinthians",
+ "1 Cor": "1 Corinthians",
+ "1Co": "1 Corinthians",
+ "2Cor": "2 Corinthians",
+ "2 Cor": "2 Corinthians",
+ "2Co": "2 Corinthians",
+ "Gal": "Galatians",
+ "Ga": "Galatians",
+ "Eph": "Ephesians",
+ "Ep": "Ephesians",
+ "Phil": "Philippians",
+ "Php": "Philippians",
+ "Ph": "Philippians",
+ "Col": "Colossians",
+ "Co": "Colossians",
+ "1Thess": "1 Thessalonians",
+ "1 Thess": "1 Thessalonians",
+ "1Th": "1 Thessalonians",
+ "2Thess": "2 Thessalonians",
+ "2 Thess": "2 Thessalonians",
+ "2Th": "2 Thessalonians",
+ "1Tim": "1 Timothy",
+ "1 Tim": "1 Timothy",
+ "1Ti": "1 Timothy",
+ "2Tim": "2 Timothy",
+ "2 Tim": "2 Timothy",
+ "2Ti": "2 Timothy",
+ "Tit": "Titus",
+ "Ti": "Titus",
+ "Phm": "Philemon",
+ "Pm": "Philemon",
+ "Heb": "Hebrews",
+ "He": "Hebrews",
+ "Jam": "James",
+ "Jas": "James",
+ "Jm": "James",
+ "1Pet": "1 Peter",
+ "1 Pet": "1 Peter",
+ "1Pe": "1 Peter",
+ "1P": "1 Peter",
+ "2Pet": "2 Peter",
+ "2 Pet": "2 Peter",
+ "2Pe": "2 Peter",
+ "2P": "2 Peter",
+ "1Joh": "1 John",
+ "1 Joh": "1 John",
+ "1Jn": "1 John",
+ "2Joh": "2 John",
+ "2 Joh": "2 John",
+ "2Jn": "2 John",
+ "3Joh": "3 John",
+ "3 Joh": "3 John",
+ "3Jn": "3 John",
+ "Jud": "Jude",
+ "Rev": "Revelation",
+ "Re": "Revelation",
+}
+
+
+def normalize_book_name(book: str) -> Optional[str]:
+ """
+ Normalize book name variations to canonical form.
+ Returns the canonical book name if a variation is detected, None otherwise.
+ """
+ return BOOK_ABBREVIATIONS.get(book)
+
+
+def is_old_testament(book: str) -> bool:
+ """Check if a book is in the Old Testament."""
+ return book in OT_BOOKS
+
+
+def is_new_testament(book: str) -> bool:
+ """Check if a book is in the New Testament."""
+ return book in NT_BOOKS
+
+
+def get_testament(book: str) -> str:
+ """Return the testament name for a book."""
+ if is_old_testament(book):
+ return "Old Testament"
+ elif is_new_testament(book):
+ return "New Testament"
+ return "Unknown"
diff --git a/kjvstudy_org/utils/helpers.py b/kjvstudy_org/utils/helpers.py
new file mode 100644
index 0000000..737ab78
--- /dev/null
+++ b/kjvstudy_org/utils/helpers.py
@@ -0,0 +1,335 @@
+"""Helper utilities for KJV Study."""
+import re
+import hashlib
+from datetime import datetime
+from typing import Optional, Dict, List
+
+from ..kjv import bible, VerseReference
+from ..topics import get_all_topics
+
+
+def create_slug(text: str) -> str:
+ """Convert text to URL-friendly slug."""
+ slug = re.sub(r'[^\w\s-]', '', text.lower())
+ slug = re.sub(r'[-\s]+', '-', slug)
+ return slug.strip('-')
+
+
+def get_verse_text(book: str, chapter: int, verse: int) -> str:
+ """Get the actual text of a specific verse."""
+ try:
+ text = bible.get_verse_text(book, chapter, verse)
+ if text:
+ return text
+ return f"{book} {chapter}:{verse} text not found"
+ except:
+ return f"{book} {chapter}:{verse}"
+
+
+def is_verse_reference(query: str) -> bool:
+ """Check if query looks like a verse reference."""
+ # Pattern for verse references like "John 3:16", "1 John 4:8", "Genesis 1:1", etc.
+ verse_pattern = r'^(I{1,3}|1|2|3)?\s*[A-Za-z]+(\s+[A-Za-z]+)?\s+\d+:\d+$'
+ return bool(re.match(verse_pattern, query.strip()))
+
+
+def parse_verse_reference(query: str) -> Optional[Dict]:
+ """Parse a verse reference string and return verse info if found."""
+ try:
+ cleaned_query = query.strip()
+ verse_ref = VerseReference.from_string(cleaned_query)
+ verse_text = bible.get_verse_text(verse_ref.book, verse_ref.chapter, verse_ref.verse)
+
+ if verse_text:
+ return {
+ "book": verse_ref.book,
+ "chapter": verse_ref.chapter,
+ "verse": verse_ref.verse,
+ "text": verse_text,
+ "reference": f"{verse_ref.book} {verse_ref.chapter}:{verse_ref.verse}",
+ "url": f"/book/{verse_ref.book}/chapter/{verse_ref.chapter}#verse-{verse_ref.verse}",
+ "score": 100.0,
+ "highlighted_text": verse_text
+ }
+ except Exception as e:
+ print(f"Error parsing verse reference '{query}': {e}")
+
+ # Try alternative book name formats (Roman numerals to numbers)
+ try:
+ alternative_query = query.strip()
+ alternative_query = re.sub(r'^I\s+', '1 ', alternative_query)
+ alternative_query = re.sub(r'^II\s+', '2 ', alternative_query)
+ alternative_query = re.sub(r'^III\s+', '3 ', alternative_query)
+
+ if alternative_query != query.strip():
+ verse_ref = VerseReference.from_string(alternative_query)
+ verse_text = bible.get_verse_text(verse_ref.book, verse_ref.chapter, verse_ref.verse)
+
+ if verse_text:
+ return {
+ "book": verse_ref.book,
+ "chapter": verse_ref.chapter,
+ "verse": verse_ref.verse,
+ "text": verse_text,
+ "reference": f"{verse_ref.book} {verse_ref.chapter}:{verse_ref.verse}",
+ "url": f"/book/{verse_ref.book}/chapter/{verse_ref.chapter}#verse-{verse_ref.verse}",
+ "score": 100.0,
+ "highlighted_text": verse_text
+ }
+ except Exception as e2:
+ print(f"Alternative parsing also failed for '{query}': {e2}")
+
+ return None
+
+
+def get_related_content(book: str, chapter: int = None, verse: int = None) -> Dict:
+ """Get related study guides, topics, and resources for a given passage."""
+ related = {
+ "study_guides": [],
+ "topics": [],
+ "people": [],
+ "resources": []
+ }
+
+ # Map books to related people
+ book_people_map = {
+ "Genesis": [{"name": "Abraham", "url": "/family-tree"}, {"name": "Jacob", "url": "/family-tree"}],
+ "Exodus": [{"name": "Moses", "url": "/biblical-prophets/moses"}],
+ "1 Samuel": [{"name": "Samuel", "url": "/biblical-prophets"}],
+ "2 Samuel": [{"name": "David", "url": "/family-tree"}],
+ "1 Kings": [{"name": "Elijah", "url": "/biblical-prophets/elijah"}],
+ "2 Kings": [{"name": "Elijah", "url": "/biblical-prophets/elijah"}, {"name": "Elisha", "url": "/biblical-prophets"}],
+ "Isaiah": [{"name": "Isaiah", "url": "/biblical-prophets/isaiah"}],
+ "Jeremiah": [{"name": "Jeremiah", "url": "/biblical-prophets/jeremiah"}],
+ "Ezekiel": [{"name": "Ezekiel", "url": "/biblical-prophets/ezekiel"}],
+ "Daniel": [{"name": "Daniel", "url": "/biblical-prophets/daniel"}],
+ "Jonah": [{"name": "Jonah", "url": "/biblical-prophets/jonah"}],
+ "Matthew": [{"name": "The Twelve Apostles", "url": "/the-twelve-apostles"}],
+ "Mark": [{"name": "The Twelve Apostles", "url": "/the-twelve-apostles"}],
+ "Luke": [{"name": "The Twelve Apostles", "url": "/the-twelve-apostles"}, {"name": "John the Baptist", "url": "/biblical-prophets/john-the-baptist"}],
+ "John": [{"name": "John", "url": "/the-twelve-apostles/john"}],
+ "Acts": [{"name": "Peter", "url": "/the-twelve-apostles/peter"}, {"name": "Paul", "url": "/the-twelve-apostles"}],
+ "Ruth": [{"name": "Ruth", "url": "/women-of-the-bible/ruth"}],
+ "Esther": [{"name": "Esther", "url": "/women-of-the-bible/esther"}],
+ }
+
+ if book in book_people_map:
+ related["people"] = book_people_map[book]
+
+ # Map books/passages to special resources
+ if book in ["Exodus", "Leviticus", "Numbers", "Deuteronomy"]:
+ related["resources"].append({"name": "Biblical Festivals", "url": "/biblical-festivals"})
+ related["resources"].append({"name": "Biblical Covenants", "url": "/biblical-covenants"})
+
+ if book in ["Genesis", "Exodus", "Numbers"]:
+ related["resources"].append({"name": "Biblical Timeline", "url": "/biblical-timeline"})
+
+ if book in ["Joshua", "Judges", "1 Samuel", "2 Samuel", "1 Kings", "2 Kings"]:
+ related["resources"].append({"name": "Biblical Maps", "url": "/biblical-maps"})
+
+ if book in ["Matthew", "Mark", "Luke", "John"]:
+ related["resources"].append({"name": "Parables of Jesus", "url": "/parables"})
+
+ # Add topic links based on common themes
+ topic_keywords = {
+ "Salvation": ["John", "Romans", "Ephesians", "Titus"],
+ "Prayer": ["Matthew", "Luke", "1 Thessalonians", "James"],
+ "Love": ["John", "1 Corinthians", "1 John"],
+ "Faith": ["Hebrews", "James", "Romans"],
+ "Hope": ["Romans", "1 Peter", "Hebrews"],
+ "Peace": ["Philippians", "John", "Romans"],
+ "Wisdom": ["Proverbs", "Ecclesiastes", "James"],
+ }
+
+ topics_data = get_all_topics()
+ for topic_name in topics_data.keys():
+ if topic_name in topic_keywords and book in topic_keywords[topic_name]:
+ related["topics"].append({"name": topic_name, "url": f"/topics/{topic_name}"})
+
+ return related
+
+
+# Popular/well-known chapters with their scores (1-10 scale)
+POPULAR_CHAPTERS = {
+ "John": {3: 10},
+ "1 Corinthians": {13: 10},
+ "Psalms": {23: 10, 91: 9, 1: 8, 139: 8},
+ "Romans": {8: 9, 3: 8, 12: 8},
+ "Matthew": {5: 9, 6: 8, 7: 8},
+ "Ephesians": {2: 8, 6: 8},
+ "Philippians": {4: 8},
+ "Genesis": {1: 9, 3: 8, 22: 7},
+ "Exodus": {20: 8, 14: 7},
+ "Isaiah": {53: 9, 40: 8},
+ "Jeremiah": {29: 7},
+ "Proverbs": {31: 7, 3: 7},
+ "Ecclesiastes": {3: 8},
+ "1 Peter": {5: 7},
+ "James": {1: 7},
+ "Hebrews": {11: 8, 12: 7},
+ "Revelation": {21: 8, 22: 7},
+ "Luke": {2: 9, 15: 8},
+ "2 Timothy": {3: 7},
+ "Joshua": {1: 7},
+ "Daniel": {3: 7, 6: 7},
+ "1 John": {4: 8},
+ "Galatians": {5: 7},
+ "Colossians": {3: 7},
+ "1 Thessalonians": {4: 7},
+ "Mark": {16: 7},
+ "Acts": {2: 8},
+ "1 Samuel": {17: 7},
+ "Job": {19: 7},
+ "2 Corinthians": {5: 7},
+ "1 Kings": {3: 6, 18: 6},
+ "Malachi": {3: 6},
+ "Joel": {2: 6},
+ "Micah": {6: 6},
+ "Habakkuk": {2: 6},
+}
+
+HIGH_READERSHIP_BOOKS = [
+ "Matthew", "Mark", "Luke", "John", "Acts", "Romans",
+ "1 Corinthians", "2 Corinthians", "Galatians", "Ephesians",
+ "Philippians", "Colossians", "Genesis", "Exodus", "Psalms", "Proverbs"
+]
+
+
+def get_chapter_popularity_score(book: str, chapter: int) -> int:
+ """Calculate popularity score for a chapter (1-10 scale) based on well-known verses."""
+ if book in POPULAR_CHAPTERS and chapter in POPULAR_CHAPTERS[book]:
+ return POPULAR_CHAPTERS[book][chapter]
+
+ default_score = 4
+
+ if chapter == 1:
+ default_score += 1
+
+ if book in HIGH_READERSHIP_BOOKS:
+ default_score += 1
+
+ total_chapters = len([ch for bk, ch in bible.iter_chapters() if bk == book])
+ if total_chapters <= 5:
+ default_score += 1
+
+ return min(default_score, 6)
+
+
+# Chapter explanations for popular chapters
+CHAPTER_EXPLANATIONS = {
+ "John": {
+ 3: "Contains John 3:16 - 'For God so loved the world' - the most quoted verse in Christianity",
+ 1: "The Word became flesh - Jesus as the eternal Logos and the calling of the first disciples",
+ },
+ "1 Corinthians": {
+ 13: "The famous 'Love Chapter' - 'Love is patient, love is kind' - essential reading for weddings and Christian living",
+ },
+ "Psalms": {
+ 23: "The beloved Shepherd Psalm - 'The Lord is my shepherd, I shall not want' - comfort in times of trouble",
+ 91: "Psalm of protection - 'He who dwells in the shelter of the Most High' - promises of God's care",
+ 1: "The blessed man - contrasts the righteous and wicked, foundation of wisdom literature",
+ 139: "God's omniscience and omnipresence - 'You have searched me and known me' - intimate knowledge of God",
+ },
+ "Romans": {
+ 8: "No condemnation in Christ - 'All things work together for good' - assurance of salvation",
+ 3: "All have sinned - universal need for salvation and justification by faith",
+ 12: "Living sacrifice - practical Christian living and spiritual gifts",
+ },
+ "Matthew": {
+ 5: "The Beatitudes - 'Blessed are the poor in spirit' - foundation of Christian ethics",
+ 6: "The Lord's Prayer and teachings on worry - 'Give us this day our daily bread'",
+ 7: "Golden Rule and narrow gate - 'Do unto others as you would have them do unto you'",
+ },
+ "Genesis": {
+ 1: "Creation account - 'In the beginning God created the heavens and the earth'",
+ 3: "The Fall - Adam and Eve's disobedience and the first promise of redemption",
+ 22: "Abraham's ultimate test - the near-sacrifice of Isaac, foreshadowing Christ",
+ },
+ "Isaiah": {
+ 53: "The Suffering Servant - 'He was wounded for our transgressions' - prophecy of Christ's crucifixion",
+ 40: "Comfort my people - 'Every valley shall be exalted' - hope and restoration",
+ },
+}
+
+
+def get_chapter_popularity_explanation(book: str, chapter: int) -> str:
+ """Get explanation for why a chapter is popular or what it contains."""
+ if book in CHAPTER_EXPLANATIONS and chapter in CHAPTER_EXPLANATIONS[book]:
+ return CHAPTER_EXPLANATIONS[book][chapter]
+
+ if chapter == 1:
+ return f"Opening chapter of {book} - introduces key themes and characters"
+
+ if book in ["Matthew", "Mark", "Luke", "John"]:
+ return "Gospel account of Jesus' life and ministry"
+ elif book in ["Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy"]:
+ return "Torah/Pentateuch - foundational law and history of Israel"
+ elif book in ["Psalms", "Proverbs", "Ecclesiastes", "Song of Solomon"]:
+ return "Wisdom literature - poetry and practical life guidance"
+ elif book in ["Isaiah", "Jeremiah", "Ezekiel", "Daniel"]:
+ return "Major prophet - messages of judgment and hope"
+ elif book in ["Romans", "1 Corinthians", "2 Corinthians", "Galatians", "Ephesians",
+ "Philippians", "Colossians", "1 Thessalonians", "2 Thessalonians",
+ "1 Timothy", "2 Timothy", "Titus", "Philemon"]:
+ return "Pauline epistle - apostolic teaching for the early church"
+ elif book == "Acts":
+ return "History of the early church and spread of the gospel"
+ elif book == "Revelation":
+ return "Apocalyptic vision of the end times and Christ's victory"
+ else:
+ return f"Part of {book} - explore this chapter to discover its significance"
+
+
+# Featured verses for verse of the day
+FEATURED_VERSES = [
+ ("John", 3, 16),
+ ("Psalms", 23, 1),
+ ("Proverbs", 3, 5),
+ ("Philippians", 4, 13),
+ ("Romans", 8, 28),
+ ("Isaiah", 40, 31),
+ ("Jeremiah", 29, 11),
+ ("Joshua", 1, 9),
+ ("Matthew", 11, 28),
+ ("Psalms", 46, 10),
+ ("Romans", 12, 2),
+ ("2 Timothy", 1, 7),
+ ("Proverbs", 22, 6),
+ ("1 Corinthians", 13, 4),
+ ("Galatians", 5, 22),
+ ("Hebrews", 11, 1),
+ ("James", 1, 2),
+ ("1 Peter", 5, 7),
+ ("Psalms", 119, 105),
+ ("Matthew", 6, 33),
+ ("John", 14, 6),
+ ("Romans", 5, 8),
+ ("Ephesians", 2, 8),
+ ("Psalms", 27, 1),
+ ("Isaiah", 41, 10),
+ ("Matthew", 28, 19),
+ ("John", 1, 1),
+ ("Psalms", 51, 10),
+ ("Proverbs", 18, 10),
+ ("2 Corinthians", 5, 17),
+ ("Colossians", 3, 23),
+]
+
+
+def get_daily_verse() -> Dict:
+ """Get the verse of the day based on the current date."""
+ today = datetime.now()
+ day_of_year = today.timetuple().tm_yday
+ verse_index = day_of_year % len(FEATURED_VERSES)
+
+ book, chapter, verse = FEATURED_VERSES[verse_index]
+ verse_text = bible.get_verse_text(book, chapter, verse)
+
+ return {
+ "book": book,
+ "chapter": chapter,
+ "verse": verse,
+ "text": verse_text,
+ "reference": f"{book} {chapter}:{verse}",
+ "url": f"/book/{book}/chapter/{chapter}#verse-{verse}"
+ }
diff --git a/kjvstudy_org/utils/search.py b/kjvstudy_org/utils/search.py
new file mode 100644
index 0000000..8833666
--- /dev/null
+++ b/kjvstudy_org/utils/search.py
@@ -0,0 +1,101 @@
+"""Search functionality for Bible verses."""
+from typing import List, Dict, Optional
+
+from ..kjv import bible
+from .helpers import is_verse_reference, parse_verse_reference
+
+# Try to use FTS5 search if available
+_use_fts = False
+try:
+ from .search_index import search_verses as fts_search, init_search_index, DB_PATH
+ _use_fts = True
+except ImportError:
+ pass
+
+
+def perform_full_text_search(query: str, limit: Optional[int] = None) -> List[Dict]:
+ """
+ Perform full text search across all Bible verses.
+
+ Uses SQLite FTS5 for efficient searching when available,
+ falls back to O(n) iteration otherwise.
+ """
+ # First, check if this looks like a verse reference
+ if is_verse_reference(query):
+ verse_result = parse_verse_reference(query)
+ if verse_result:
+ return [verse_result]
+
+ # Use FTS5 if available and index exists
+ if _use_fts and DB_PATH.exists():
+ return fts_search(query, limit)
+
+ # Fallback to original O(n) search
+ return _legacy_search(query, limit)
+
+
+def _legacy_search(query: str, limit: Optional[int] = None) -> List[Dict]:
+ """Original O(n) search through all verses - used as fallback."""
+ results = []
+ search_terms = query.lower().split()
+
+ for verse in bible.iter_verses():
+ verse_text = verse.text.lower()
+
+ # Check if all search terms are in the verse
+ if all(term in verse_text for term in search_terms):
+ score = calculate_relevance_score(verse.text, search_terms)
+
+ results.append({
+ "book": verse.book,
+ "chapter": verse.chapter,
+ "verse": verse.verse,
+ "text": verse.text,
+ "reference": f"{verse.book} {verse.chapter}:{verse.verse}",
+ "url": f"/book/{verse.book}/chapter/{verse.chapter}#verse-{verse.verse}",
+ "score": score,
+ "highlighted_text": highlight_search_terms(verse.text, search_terms)
+ })
+
+ # Sort by relevance score (higher is better)
+ results.sort(key=lambda x: x["score"], reverse=True)
+
+ if limit is not None:
+ return results[:limit]
+ return results
+
+
+def calculate_relevance_score(text: str, search_terms: List[str]) -> float:
+ """Calculate relevance score for search results."""
+ text_lower = text.lower()
+ score = 0.0
+
+ for term in search_terms:
+ # Count occurrences of each term
+ count = text_lower.count(term.lower())
+ score += count
+
+ # Bonus for exact word matches
+ if f" {term.lower()} " in f" {text_lower} ":
+ score += 0.5
+
+ return score
+
+
+def highlight_search_terms(text: str, search_terms: List[str]) -> str:
+ """Highlight search terms in text."""
+ import re
+ highlighted = text
+ for term in search_terms:
+ # Case-insensitive replacement
+ pattern = re.compile(re.escape(term), re.IGNORECASE)
+ highlighted = pattern.sub(f'{term}', highlighted)
+ return highlighted
+
+
+def ensure_search_index():
+ """Ensure the FTS5 search index is built."""
+ if _use_fts:
+ init_search_index()
+ return True
+ return False
diff --git a/kjvstudy_org/utils/search_index.py b/kjvstudy_org/utils/search_index.py
new file mode 100644
index 0000000..e8b1493
--- /dev/null
+++ b/kjvstudy_org/utils/search_index.py
@@ -0,0 +1,249 @@
+"""
+SQLite FTS5 search index for fast Bible verse search.
+
+This module provides a full-text search index using SQLite's FTS5 extension,
+enabling efficient searches across all 31,102 Bible verses.
+"""
+import sqlite3
+from pathlib import Path
+from typing import List, Dict, Optional
+from contextlib import contextmanager
+
+from ..kjv import bible
+
+# Database location - store in static directory alongside other data
+DB_PATH = Path(__file__).parent.parent / "static" / "search_index.db"
+
+# Global connection for reuse
+_connection: Optional[sqlite3.Connection] = None
+
+
+@contextmanager
+def get_connection():
+ """Get a database connection with proper cleanup."""
+ global _connection
+ if _connection is None:
+ _connection = sqlite3.connect(str(DB_PATH), check_same_thread=False)
+ _connection.row_factory = sqlite3.Row
+ yield _connection
+
+
+def init_search_index(force_rebuild: bool = False) -> bool:
+ """
+ Initialize the FTS5 search index.
+
+ Creates the database and populates it with all Bible verses if it doesn't exist.
+ Returns True if the index was created/rebuilt, False if it already existed.
+ """
+ if DB_PATH.exists() and not force_rebuild:
+ return False
+
+ # Ensure directory exists
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+ # Remove old database if rebuilding
+ if DB_PATH.exists():
+ DB_PATH.unlink()
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Create FTS5 virtual table for full-text search
+ cursor.execute("""
+ CREATE VIRTUAL TABLE IF NOT EXISTS verses_fts USING fts5(
+ book,
+ chapter,
+ verse,
+ text,
+ reference,
+ tokenize='porter unicode61'
+ )
+ """)
+
+ # Create regular table for metadata and fast lookups
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS verses (
+ id INTEGER PRIMARY KEY,
+ book TEXT NOT NULL,
+ chapter INTEGER NOT NULL,
+ verse INTEGER NOT NULL,
+ text TEXT NOT NULL,
+ reference TEXT NOT NULL,
+ UNIQUE(book, chapter, verse)
+ )
+ """)
+
+ # Create indexes for common queries
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_book ON verses(book)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_book_chapter ON verses(book, chapter)")
+
+ # Populate with all verses
+ print("Building search index...")
+ batch = []
+ batch_size = 1000
+ total = 0
+
+ for verse in bible.iter_verses():
+ reference = f"{verse.book} {verse.chapter}:{verse.verse}"
+ row = (verse.book, verse.chapter, verse.verse, verse.text, reference)
+ batch.append(row)
+ total += 1
+
+ if len(batch) >= batch_size:
+ cursor.executemany(
+ "INSERT INTO verses (book, chapter, verse, text, reference) VALUES (?, ?, ?, ?, ?)",
+ batch
+ )
+ cursor.executemany(
+ "INSERT INTO verses_fts (book, chapter, verse, text, reference) VALUES (?, ?, ?, ?, ?)",
+ batch
+ )
+ batch = []
+
+ # Insert remaining
+ if batch:
+ cursor.executemany(
+ "INSERT INTO verses (book, chapter, verse, text, reference) VALUES (?, ?, ?, ?, ?)",
+ batch
+ )
+ cursor.executemany(
+ "INSERT INTO verses_fts (book, chapter, verse, text, reference) VALUES (?, ?, ?, ?, ?)",
+ batch
+ )
+
+ conn.commit()
+ print(f"Search index built with {total} verses")
+
+ return True
+
+
+def search_verses(
+ query: str,
+ limit: Optional[int] = None,
+ book_filter: Optional[str] = None,
+ testament_filter: Optional[str] = None
+) -> List[Dict]:
+ """
+ Search for verses matching the query using FTS5.
+
+ Args:
+ query: Search terms (supports FTS5 query syntax)
+ limit: Maximum number of results
+ book_filter: Filter to specific book
+ testament_filter: Filter to "old" or "new" testament
+
+ Returns:
+ List of matching verses with relevance scores
+ """
+ # Ensure index exists
+ if not DB_PATH.exists():
+ init_search_index()
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Build the FTS5 query
+ # Escape special FTS5 characters and prepare search terms
+ search_terms = query.strip()
+ if not search_terms:
+ return []
+
+ # For simple queries, search for all terms
+ # FTS5 will handle ranking by relevance
+ fts_query = ' '.join(f'"{term}"' for term in search_terms.split())
+
+ # Build SQL with optional filters
+ sql = """
+ SELECT
+ book,
+ chapter,
+ verse,
+ text,
+ reference,
+ bm25(verses_fts) as score
+ FROM verses_fts
+ WHERE verses_fts MATCH ?
+ """
+ params = [fts_query]
+
+ if book_filter:
+ sql += " AND book = ?"
+ params.append(book_filter)
+
+ if testament_filter:
+ ot_books = [
+ 'Genesis', 'Exodus', 'Leviticus', 'Numbers', 'Deuteronomy',
+ 'Joshua', 'Judges', 'Ruth', '1 Samuel', '2 Samuel',
+ '1 Kings', '2 Kings', '1 Chronicles', '2 Chronicles',
+ 'Ezra', 'Nehemiah', 'Esther', 'Job', 'Psalms', 'Proverbs',
+ 'Ecclesiastes', 'Song of Solomon', 'Isaiah', 'Jeremiah',
+ 'Lamentations', 'Ezekiel', 'Daniel', 'Hosea', 'Joel', 'Amos',
+ 'Obadiah', 'Jonah', 'Micah', 'Nahum', 'Habakkuk', 'Zephaniah',
+ 'Haggai', 'Zechariah', 'Malachi'
+ ]
+ if testament_filter.lower() == 'old':
+ placeholders = ','.join('?' * len(ot_books))
+ sql += f" AND book IN ({placeholders})"
+ params.extend(ot_books)
+ elif testament_filter.lower() == 'new':
+ placeholders = ','.join('?' * len(ot_books))
+ sql += f" AND book NOT IN ({placeholders})"
+ params.extend(ot_books)
+
+ # Order by relevance (bm25 score - lower is better in SQLite)
+ sql += " ORDER BY score"
+
+ if limit:
+ sql += " LIMIT ?"
+ params.append(limit)
+
+ cursor.execute(sql, params)
+
+ results = []
+ for row in cursor.fetchall():
+ results.append({
+ "book": row["book"],
+ "chapter": row["chapter"],
+ "verse": row["verse"],
+ "text": row["text"],
+ "reference": row["reference"],
+ "url": f"/book/{row['book']}/chapter/{row['chapter']}#verse-{row['verse']}",
+ "score": abs(row["score"]), # BM25 returns negative, we want positive
+ "highlighted_text": highlight_matches(row["text"], query)
+ })
+
+ return results
+
+
+def highlight_matches(text: str, query: str) -> str:
+ """Highlight matching terms in text."""
+ highlighted = text
+ for term in query.lower().split():
+ # Case-insensitive replacement with highlighting
+ import re
+ pattern = re.compile(re.escape(term), re.IGNORECASE)
+ highlighted = pattern.sub(f'{term}', highlighted)
+ return highlighted
+
+
+def get_search_stats() -> Dict:
+ """Get statistics about the search index."""
+ if not DB_PATH.exists():
+ return {"indexed": False, "verses": 0}
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM verses")
+ count = cursor.fetchone()[0]
+
+ return {
+ "indexed": True,
+ "verses": count,
+ "db_size_mb": round(DB_PATH.stat().st_size / (1024 * 1024), 2)
+ }
+
+
+# Initialize on import if database doesn't exist
+if not DB_PATH.exists():
+ # Don't auto-init during import - call init_search_index() explicitly
+ pass
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
index 6ab6d71..9f455db 100644
--- a/tests/test_edge_cases.py
+++ b/tests/test_edge_cases.py
@@ -51,8 +51,8 @@ class TestVerseRangeEdgeCases:
def test_reversed_verse_range(self, client):
"""Test verse range with start > end"""
response = client.get("/api/verse-range/John/3/16/1")
- # Should handle reversed ranges gracefully
- assert response.status_code in [200, 400, 422, 500]
+ # Should handle reversed ranges gracefully - 404 is acceptable (no verses found)
+ assert response.status_code in [200, 400, 404, 422, 500]
def test_single_verse_range(self, client):
"""Test verse range with start = end"""