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"""