Refactor: Split server.py into modular packages

- Create kjvstudy_org/utils/ package:
  - books.py: Book name normalization, abbreviations, testament categorization
  - helpers.py: Utility functions (create_slug, get_verse_text, etc.)
  - search.py: Full-text search with FTS5 support
  - search_index.py: SQLite FTS5 search index implementation

- Create kjvstudy_org/routes/ package:
  - api.py: All /api/* endpoints as FastAPI router

- Update server.py:
  - Import from new modular packages
  - Include API router via app.include_router()
  - Remove duplicate API routes

- Fix test_edge_cases.py to accept 404 for reversed verse ranges

All 100 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 18:59:35 -05:00
parent d694c822ee
commit 83df2a37ed
9 changed files with 1407 additions and 439 deletions
+6
View File
@@ -0,0 +1,6 @@
"""Routes package for KJV Study."""
from fastapi import APIRouter
from .api import router as api_router
__all__ = ['api_router']
+442
View File
@@ -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
+14 -437
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
# Utility modules for KJV Study
# Note: Import individual modules to avoid circular imports
__all__ = [
'books',
'search',
'helpers',
]
+250
View File
@@ -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"
+335
View File
@@ -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}"
}
+101
View File
@@ -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'<mark>{term}</mark>', highlighted)
return highlighted
def ensure_search_index():
"""Ensure the FTS5 search index is built."""
if _use_fts:
init_search_index()
return True
return False
+249
View File
@@ -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'<mark>{term}</mark>', 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
+2 -2
View File
@@ -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"""