import hashlib import json import os import re import random from contextlib import asynccontextmanager from datetime import datetime, timedelta from pathlib import Path as PathLib from typing import List, Dict, Optional from fastapi import FastAPI, HTTPException, Request, Query, Path from fastapi.exception_handlers import http_exception_handler from fastapi.responses import HTMLResponse, Response, RedirectResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.middleware.gzip import GZipMiddleware from fastapi.openapi.utils import get_openapi from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.base import BaseHTTPMiddleware from .kjv import bible, VerseReference from .cross_references import get_cross_references 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 from .books import get_book_data, has_book_data # Import from modular packages from .routes import ( api_router, init_api_templates, resources_router, init_resources_templates, family_tree_router, init_family_tree_templates, study_guides_router, init_study_guides_templates, commentary_router, init_commentary_templates, stories_router, init_stories_templates, utility_router, ) from .routes.commentary import ( generate_commentary, generate_chapter_overview, generate_book_commentary, generate_word_study_sidenotes, ) 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, is_verse_reference, parse_verse_reference ) from .utils.pdf import WEASYPRINT_AVAILABLE, render_html_to_pdf, render_html_to_pdf_async from .utils.search import perform_full_text_search try: from ged4py import GedcomReader except ImportError: GedcomReader = None # Note: Helper functions (create_slug, normalize_book_name, get_related_content, # get_chapter_popularity_score, get_chapter_popularity_explanation, get_verse_text, # is_verse_reference, parse_verse_reference, perform_full_text_search, etc.) # are now imported from utils modules above. @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup/shutdown events""" # Startup # Initialize search index for fast searches from .utils.search import ensure_search_index ensure_search_index() if os.getenv("PRELOAD_INTERLINEAR", "false").lower() == "true": preload_data() yield # Shutdown (nothing needed currently) app = FastAPI( title="KJV Study API", description="RESTful API for accessing King James Bible verses, chapters, and study resources", version="1.0.0", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", lifespan=lifespan ) # Include the API router (routes defined in routes/api.py) app.include_router(api_router) # Include the resources router (biblical resources, defined in routes/resources.py) app.include_router(resources_router) # Include the family tree router app.include_router(family_tree_router) # Include the study guides router app.include_router(study_guides_router) # Include the commentary router app.include_router(commentary_router) # Include the stories router app.include_router(stories_router) # Include the utility router (sitemap, robots.txt, health) app.include_router(utility_router) # Custom OpenAPI schema to only include /api routes def custom_openapi(): if app.openapi_schema: return app.openapi_schema openapi_schema = get_openapi( title=app.title, version=app.version, description=app.description, routes=app.routes, ) # Filter paths to only include /api routes filtered_paths = { path: path_item for path, path_item in openapi_schema["paths"].items() if path.startswith("/api/") } openapi_schema["paths"] = filtered_paths app.openapi_schema = openapi_schema return app.openapi_schema app.openapi = custom_openapi # Caching middleware for performance optimization class CacheControlMiddleware(BaseHTTPMiddleware): """Add cache control headers to responses for better performance""" async def dispatch(self, request: Request, call_next): response = await call_next(request) # Skip caching for API endpoints and dynamic content if request.url.path.startswith("/api/") or request.url.path == "/verse-of-the-day": response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" # Static files (CSS, JS, images) - cache for 1 year elif request.url.path.startswith("/static/"): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" # Bible content (verses, chapters, books) - cache for 1 week (rarely changes) elif any(x in request.url.path for x in ["/book/", "/chapter/", "/verse/"]): response.headers["Cache-Control"] = "public, max-age=604800" # 1 week # Study resources and special pages - cache for 1 day elif any(x in request.url.path for x in ["/study-guides/", "/topics/", "/reading-plans/", "/biblical-", "/names-of-god", "/parables/", "/the-twelve-apostles/", "/women-of-the-bible/", "/tetragrammaton", "/commentary/"]): response.headers["Cache-Control"] = "public, max-age=86400" # 1 day # Homepage and main sections - cache for 1 hour elif request.url.path in ["/", "/books", "/search", "/resources", "/concordance"]: response.headers["Cache-Control"] = "public, max-age=3600" # 1 hour # Sitemap and robots.txt - cache for 1 day elif request.url.path in ["/sitemap.xml", "/robots.txt"]: response.headers["Cache-Control"] = "public, max-age=86400" # Default - cache for 10 minutes else: response.headers["Cache-Control"] = "public, max-age=600" return response # Bot detection and logging middleware class BotLoggerMiddleware(BaseHTTPMiddleware): """Log requests from bots/crawlers only""" # Common bot identifiers to detect BOT_IDENTIFIERS = [ 'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider', 'yandexbot', 'facebookexternalhit', 'twitterbot', 'rogerbot', 'linkedinbot', 'embedly', 'quora link preview', 'showyoubot', 'outbrain', 'pinterest', 'slackbot', 'vkshare', 'w3c_validator', 'redditbot', 'applebot', 'whatsapp', 'flipboard', 'tumblr', 'bitlybot', 'skypeuripreview', 'nuzzel', 'discordbot', 'telegrambot', 'perplexitybot', 'amazonbot', 'claudebot', 'anthropic-ai', 'gptbot', 'chatgpt-user', 'ccbot', 'claudebot', 'diffbot', 'bytespider', 'petalbot' ] async def dispatch(self, request: Request, call_next): user_agent = request.headers.get("user-agent", "").lower() # Check if this is a bot is_bot = any(bot in user_agent for bot in self.BOT_IDENTIFIERS) if is_bot: # Extract the bot name for cleaner logging bot_name = next((bot for bot in self.BOT_IDENTIFIERS if bot in user_agent), "unknown bot") print(f"[BOT] {bot_name}") response = await call_next(request) return response # Add GZip compression middleware (compress responses > 500 bytes) app.add_middleware(GZipMiddleware, minimum_size=500) # Add caching middleware app.add_middleware(CacheControlMiddleware) # Add bot logging middleware app.add_middleware(BotLoggerMiddleware) # Set up Jinja2 templates and static files current_dir = PathLib(__file__).parent static_dir = current_dir / "static" templates_dir = current_dir / "templates" app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") templates = Jinja2Templates(directory=str(templates_dir)) # Register custom Jinja2 filters templates.env.filters['slugify'] = create_slug # Initialize mistune for markdown rendering import mistune # Create mistune instance for full markdown (with paragraphs) _markdown = mistune.create_markdown(escape=False, hard_wrap=False) # Create inline renderer for markdown without paragraph wrapping _inline_markdown = mistune.create_markdown( renderer=mistune.HTMLRenderer(escape=False), plugins=['strikethrough'] ) def markdown_inline(text): """Convert inline markdown to HTML (bold, italic, etc. - no paragraph wrapping).""" if not text: return text # Render and strip any outer

tags that mistune might add html = _inline_markdown(text).strip() if html.startswith('

') and html.endswith('

'): html = html[3:-4] return html def markdown_to_html(text): """Convert markdown to HTML (bold, italic, paragraphs, etc.).""" if not text: return text return _markdown(text).strip() templates.env.filters['md'] = markdown_to_html templates.env.filters['mdi'] = markdown_inline # Initialize templates for route modules init_api_templates(templates) init_resources_templates(templates) init_family_tree_templates(templates) init_study_guides_templates(templates) init_commentary_templates(templates) init_stories_templates(templates) # Load Scofield commentary for cross-references scofield_commentary = {} try: scofield_path = static_dir / "scofield_commentary.json" with open(scofield_path, 'r') as f: scofield_commentary = json.load(f) except Exception as e: print(f"Warning: Could not load Scofield commentary: {e}") @app.exception_handler(StarletteHTTPException) async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException): """Custom error handler that renders our error template""" if exc.status_code == 404: books = bible.get_books() return templates.TemplateResponse( request, "error.html", { "status_code": exc.status_code, "detail": exc.detail, "books": books, }, status_code=exc.status_code, ) # For other errors, use the default handler return await http_exception_handler(request, exc) @app.get("/search", response_class=HTMLResponse) def search_page(request: Request, q: str = Query(None, description="Search query")): """Search page with results (includes Bible verses and family tree)""" books = bible.get_books() search_results = [] family_tree_results = [] is_direct_verse = False if q and len(q.strip()) >= 2: # Search Bible verses search_results = perform_full_text_search(q.strip()) # 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 # Also search family tree (limit to 5 results) family_tree_results = search_family_tree(q.strip(), limit=5) return templates.TemplateResponse( request, "search.html", { "query": q or "", "results": search_results, "family_tree_results": family_tree_results, "books": books, "total_results": len(search_results) + len(family_tree_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""" books = bible.get_books() if not word or len(word.strip()) < 2: return templates.TemplateResponse( request, "concordance.html", { "books": books, "word": word or "", "total_occurrences": 0, "occurrences_by_book": {}, "books_with_word": [] } ) search_word = word.strip() occurrences = [] occurrences_by_book = {} books_with_word = set() # Search through all verses import re # Create a word boundary pattern for exact word matching # This handles punctuation and word boundaries properly pattern = re.compile(r'\b' + re.escape(search_word) + r'\b', re.IGNORECASE) # Use bible.iter_verses() to iterate through all verses for verse in bible.iter_verses(): # Check if the word appears in this verse if pattern.search(verse.text): # Highlight the word in the text highlighted_text = pattern.sub( lambda m: f'{m.group()}', verse.text ) occurrence = { 'book': verse.book, 'chapter': verse.chapter, 'verse': verse.verse, 'text': verse.text, 'highlighted_text': highlighted_text } occurrences.append(occurrence) books_with_word.add(verse.book) # Group by book if verse.book not in occurrences_by_book: occurrences_by_book[verse.book] = [] occurrences_by_book[verse.book].append(occurrence) return templates.TemplateResponse( request, "concordance.html", { "books": books, "word": search_word, "total_occurrences": len(occurrences), "occurrences_by_book": occurrences_by_book, "books_with_word": sorted(books_with_word) } ) @app.get("/interlinear", response_class=HTMLResponse) def interlinear_landing_page(request: Request): """Landing page explaining interlinear Bible study""" books = bible.get_books() # Featured verses with interlinear data featured_verses = [ {"reference": "John 3:16", "url": "/book/John/chapter/3/verse/16", "note": "God's love for the world"}, {"reference": "Genesis 1:1", "url": "/book/Genesis/chapter/1/verse/1", "note": "In the beginning"}, {"reference": "Psalm 23:1", "url": "/book/Psalms/chapter/23/verse/1", "note": "The Lord is my shepherd"}, {"reference": "Romans 8:28", "url": "/book/Romans/chapter/8/verse/28", "note": "All things work together for good"}, {"reference": "Matthew 28:19", "url": "/book/Matthew/chapter/28/verse/19", "note": "The Great Commission"}, {"reference": "1 Corinthians 13:4", "url": "/book/1 Corinthians/chapter/13/verse/4", "note": "Love is patient"}, ] # Build breadcrumbs breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Interlinear", "url": None} ] return templates.TemplateResponse( request, "interlinear_landing.html", { "books": books, "featured_verses": featured_verses, "breadcrumbs": breadcrumbs } ) def verse_reference_to_url(reference: str): """Convert a verse reference to a URL path. Examples: "John 3:16" -> "/book/John/chapter/3/verse/16" "Romans 8:38-39" -> "/book/Romans/chapter/8#verse-38-39" "Ephesians 2:8-9" -> "/book/Ephesians/chapter/2#verse-8-9" """ # Pattern: Book Chapter:Verse or Book Chapter:Verse-Verse match = re.match(r'^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$', reference.strip()) if not match: return None book = match.group(1).strip() chapter = match.group(2) verse_start = match.group(3) verse_end = match.group(4) if verse_end: # Verse range - link to chapter with anchor return f"/book/{book}/chapter/{chapter}#verse-{verse_start}-{verse_end}" else: # Single verse - link to verse page return f"/book/{book}/chapter/{chapter}/verse/{verse_start}" @app.get("/random-verse") def random_verse(request: Request): """Redirect to a random Bible verse""" # Get all books all_books = bible.get_books() # Pick a random book book = random.choice(all_books) # Get all chapters for this book chapters = bible.get_chapters_for_book(book) # Pick a random chapter chapter = random.choice(chapters) # Get all verses for this chapter verses = bible.get_verses_by_book_chapter(book, chapter) # Pick a random verse verse = random.choice(verses) # Redirect to the verse page return RedirectResponse(url=f"/book/{book}/chapter/{chapter}/verse/{verse.verse}") @app.get("/verse-of-the-day", response_class=HTMLResponse) def verse_of_the_day_page(request: Request): """Verse of the day page""" books = bible.get_books() daily_verse = get_daily_verse() # Generate past 30 days of verses past_verses = [] today = datetime.now() for i in range(1, 31): # Past 30 days (not including today) past_date = today - timedelta(days=i) date_str = past_date.strftime("%Y-%m-%d") verse = get_daily_verse(date_str) past_verses.append(verse) # Build breadcrumbs breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Verse of the Day", "url": "/verse-of-the-day"} ] return templates.TemplateResponse( request, "verse_of_the_day.html", { "books": books, "daily_verse": daily_verse, "past_verses": past_verses, "breadcrumbs": breadcrumbs } ) # Note: API routes have been moved to routes/api.py and are included via app.include_router(api_router) def expand_book_abbreviation(abbrev): """Expand common Bible book abbreviations to full names""" abbreviations = { "Gen": "Genesis", "Exod": "Exodus", "Lev": "Leviticus", "Num": "Numbers", "Deut": "Deuteronomy", "Josh": "Joshua", "Judg": "Judges", "1 Sam": "1 Samuel", "2 Sam": "2 Samuel", "1 Kgs": "1 Kings", "2 Kgs": "2 Kings", "1 Chr": "1 Chronicles", "2 Chr": "2 Chronicles", "Neh": "Nehemiah", "Esth": "Esther", "Ps": "Psalms", "Prov": "Proverbs", "Eccl": "Ecclesiastes", "Song": "Song of Solomon", "Isa": "Isaiah", "Jer": "Jeremiah", "Lam": "Lamentations", "Ezek": "Ezekiel", "Dan": "Daniel", "Hos": "Hosea", "Joel": "Joel", "Amos": "Amos", "Obad": "Obadiah", "Jonah": "Jonah", "Mic": "Micah", "Nah": "Nahum", "Hab": "Habakkuk", "Zeph": "Zephaniah", "Hag": "Haggai", "Zech": "Zechariah", "Mal": "Malachi", "Matt": "Matthew", "Mark": "Mark", "Luke": "Luke", "John": "John", "Acts": "Acts", "Rom": "Romans", "1 Cor": "1 Corinthians", "2 Cor": "2 Corinthians", "Gal": "Galatians", "Eph": "Ephesians", "Phil": "Philippians", "Col": "Colossians", "1 Thess": "1 Thessalonians", "2 Thess": "2 Thessalonians", "1 Tim": "1 Timothy", "2 Tim": "2 Timothy", "Titus": "Titus", "Phlm": "Philemon", "Heb": "Hebrews", "Jas": "James", "1 Pet": "1 Peter", "2 Pet": "2 Peter", "1 John": "1 John", "2 John": "2 John", "3 John": "3 John", "Jude": "Jude", "Rev": "Revelation" } return abbreviations.get(abbrev, abbrev) def parse_verses_from_notes(note_text): """Parse Bible verse references from GEDCOM NOTE fields""" if not note_text: return [] verses = [] # Match patterns like "Gen 4:1 Text here" import re pattern = r'([123]?\s?[A-Za-z]+)\s+(\d+):(\d+)\s+(.+?)(?=(?:[123]?\s?[A-Za-z]+\s+\d+:)|$)' matches = re.finditer(pattern, note_text, re.DOTALL) for match in matches: book_abbrev = match.group(1).strip() chapter = match.group(2) verse = match.group(3) text = match.group(4).strip() # Clean up text (remove line breaks and extra spaces) text = ' '.join(text.split()) # Expand book abbreviation book_full = expand_book_abbreviation(book_abbrev) verses.append({ "reference": f"{book_full} {chapter}:{verse}", "text": text }) return verses def parse_gedcom_to_tree_data(gedcom_path): """Parse GEDCOM file into our family tree format""" tree_data = {} # Parse with ged4py using the file path directly gedcom = GedcomReader(str(gedcom_path)) # First pass: collect all individuals for record in gedcom.records0(): if record.tag == 'INDI': person_id = str(record.xref_id).replace('@', '').replace('#', '').lower() # Get person name name = "Unknown" title = "Biblical Figure" for sub in record.sub_records: if sub.tag == 'NAME': # Handle case where sub.value might be a tuple value = sub.value[0] if isinstance(sub.value, tuple) else sub.value name_value = str(value).replace('/', '').strip() name = ' '.join(name_value.split()) break # Get occupation/title from OCCU tag for sub in record.sub_records: if sub.tag == 'OCCU': value = sub.value[0] if isinstance(sub.value, tuple) else sub.value title = str(value) break # Get notes for description and parse verses description = f"Biblical figure from {name}'s genealogy" note_verses = [] for sub in record.sub_records: if sub.tag == 'NOTE': value = sub.value[0] if isinstance(sub.value, tuple) else sub.value note_text = str(value) description = note_text # Parse verses from the note note_verses = parse_verses_from_notes(note_text) break # Get birth and death dates birth_year = "Unknown" death_year = "Unknown" age_at_death = "Unknown" for sub in record.sub_records: if sub.tag == 'BIRT': for date_sub in sub.sub_records: if date_sub.tag == 'DATE': value = date_sub.value[0] if isinstance(date_sub.value, tuple) else date_sub.value birth_year = str(value) elif sub.tag == 'DEAT': for date_sub in sub.sub_records: if date_sub.tag == 'DATE': value = date_sub.value[0] if isinstance(date_sub.value, tuple) else date_sub.value death_year = str(value) # Calculate age if we have both birth and death years if birth_year != "Unknown" and death_year != "Unknown": try: birth_num = int(birth_year.split()[0]) if birth_year.split() else 0 death_num = int(death_year.split()[0]) if death_year.split() else 0 if death_num > birth_num: age_at_death = f"{death_num - birth_num} years" except (ValueError, IndexError): pass # Combine manually defined verses with verses from GEDCOM notes manual_verses = get_biblical_verses(name) all_verses = note_verses if note_verses else manual_verses person_data = { "name": name, "title": title, "description": description, "children": [], "parents": [], "siblings": [], "spouse": None, "verses": all_verses, "birth_year": birth_year, "death_year": death_year, "age_at_death": age_at_death } tree_data[person_id] = person_data # Second pass: collect family relationships for record in gedcom.records0(): if record.tag == 'FAM': husband_id = None wife_id = None children = [] for sub in record.sub_records: if sub.tag == 'HUSB': # Handle case where sub.value might be a tuple value = sub.value[0] if isinstance(sub.value, tuple) else sub.value husband_id = str(value).replace('@', '').replace('#', '').lower() elif sub.tag == 'WIFE': value = sub.value[0] if isinstance(sub.value, tuple) else sub.value wife_id = str(value).replace('@', '').replace('#', '').lower() elif sub.tag == 'CHIL': value = sub.value[0] if isinstance(sub.value, tuple) else sub.value child_id = str(value).replace('@', '').replace('#', '').lower() children.append(child_id) # Set spouse relationships if husband_id and husband_id in tree_data and wife_id and wife_id in tree_data: tree_data[husband_id]["spouse"] = tree_data[wife_id]["name"] tree_data[wife_id]["spouse"] = tree_data[husband_id]["name"] # Set parent-child relationships for child_id in children: if child_id in tree_data: if husband_id and husband_id in tree_data: tree_data[husband_id]["children"].append(child_id) if husband_id not in tree_data[child_id]["parents"]: tree_data[child_id]["parents"].append(husband_id) if wife_id and wife_id in tree_data: tree_data[wife_id]["children"].append(child_id) if wife_id not in tree_data[child_id]["parents"]: tree_data[child_id]["parents"].append(wife_id) # Third pass: calculate siblings # Siblings are people who share at least one parent for person_id, person in tree_data.items(): siblings_set = set() # Find all people who share a parent with this person for parent_id in person["parents"]: if parent_id in tree_data: # Get all children of this parent for sibling_id in tree_data[parent_id]["children"]: # Don't include the person themselves if sibling_id != person_id: siblings_set.add(sibling_id) # Convert set to list and store person["siblings"] = list(siblings_set) # Calculate generations using BFS from root people (those with no parents) generations = {} for person_id, person in tree_data.items(): person["generation"] = None # Find root people (no parents) roots = [pid for pid, person in tree_data.items() if len(person["parents"]) == 0] # BFS to assign generation numbers (from Adam forward) queue = [(pid, 1) for pid in roots] visited = set() while queue: person_id, gen_num = queue.pop(0) if person_id in visited: continue visited.add(person_id) if person_id in tree_data: tree_data[person_id]["generation"] = gen_num # Add to generations dict if gen_num not in generations: generations[gen_num] = [] generations[gen_num].append(person_id) # Add children to queue for child_id in tree_data[person_id]["children"]: if child_id not in visited: queue.append((child_id, gen_num + 1)) # Calculate Kekulé numbers (Ahnentafel numbering) from Christ # Find Jesus in the tree jesus_id = None for person_id, person in tree_data.items(): if person["name"].lower() in ["jesus", "jesus christ", "christ"]: jesus_id = person_id break # Initialize all kekule_number to None for person_id, person in tree_data.items(): person["kekule_number"] = None if jesus_id: # Kekulé numbering: person #1, father #2, mother #3 # For person #n: father = 2n, mother = 2n+1 # Work backwards from Christ using BFS queue = [(jesus_id, 1)] visited_reverse = set() while queue: person_id, kekule_num = queue.pop(0) if person_id in visited_reverse: continue visited_reverse.add(person_id) if person_id in tree_data: tree_data[person_id]["kekule_number"] = kekule_num # Get parents parents = tree_data[person_id]["parents"] # Assign Kekulé numbers to parents # Father = 2n (even), Mother = 2n+1 (odd) # We need to determine which parent is father/mother for i, parent_id in enumerate(parents): if parent_id not in visited_reverse: # Heuristic: check if parent has "male" indicators or is listed first # For biblical genealogy, typically father is listed first if i == 0: # First parent = father queue.append((parent_id, kekule_num * 2)) else: # Second parent = mother queue.append((parent_id, kekule_num * 2 + 1)) return tree_data, generations # Cache for family tree data to avoid reloading on every request _family_tree_cache = None _family_tree_generations_cache = None _name_to_person_id_cache = None def get_family_tree_data(): """Load and cache family tree data (returns tree_data and generations)""" global _family_tree_cache, _family_tree_generations_cache, _name_to_person_id_cache if _family_tree_cache is None: static_dir = PathLib(__file__).parent / "static" gedcom_path = static_dir / "adameve.ged" if gedcom_path.exists(): try: tree_data, generations = parse_gedcom_to_tree_data(gedcom_path) _family_tree_cache = tree_data _family_tree_generations_cache = generations # Build name to person_id mapping (case-insensitive) _name_to_person_id_cache = {} for person_id, person in tree_data.items(): name = person["name"] # Store both the full name and potential variations _name_to_person_id_cache[name.lower()] = person_id except Exception: _family_tree_cache = {} _family_tree_generations_cache = {} _name_to_person_id_cache = {} else: _family_tree_cache = {} _family_tree_generations_cache = {} _name_to_person_id_cache = {} return _family_tree_cache, _family_tree_generations_cache def get_person_name_mapping(): """Get the name to person ID mapping (ensures data is loaded first)""" # Trigger loading if needed get_family_tree_data() return _name_to_person_id_cache def search_family_tree(query: str, limit: Optional[int] = None) -> List[Dict]: """ Search family tree for people matching the query. Returns list of matching people with their info. """ results = [] if not query or len(query.strip()) < 2: return results try: # Use cached family tree data family_tree_data, generations = get_family_tree_data() if not family_tree_data: return results # Search for people query_lower = query.lower().strip() for person_id, person in family_tree_data.items(): if query_lower in person["name"].lower(): results.append({ "type": "person", "id": person_id, "name": person["name"], "generation": person.get("generation"), "birth_year": person.get("birth_year", "Unknown"), "death_year": person.get("death_year", "Unknown"), "url": f"/family-tree/person/{person_id}", "description": f"Generation {person.get('generation', '?')} from Adam" }) # Sort by relevance (exact matches first, then alphabetically) results.sort(key=lambda x: ( 0 if x["name"].lower() == query_lower else 1, x["name"] )) # Limit results if specified if limit is not None: return results[:limit] return results except Exception: return results def link_person_names_in_text(text: str) -> str: """ Find person names and verse references in text and link them. Links person names to family tree pages and verse references to verse pages. Avoids linking content that's already inside HTML tags. """ if not text: return text # First, link verse references (e.g., "Genesis 3:15", "1 Samuel 2:1") # Pattern matches: Book name + chapter:verse verse_pattern = r'\b((?:1|2|3)\s)?([A-Z][a-z]+(?:\s+of\s+[A-Z][a-z]+)?)\s+(\d+):(\d+)(?:-(\d+))?\b' def verse_replace_callback(match): matched_text = match.group(0) start_pos = match.start() # Check if we're inside an HTML tag text_before = text[:start_pos] last_lt = text_before.rfind('<') last_gt = text_before.rfind('>') if last_lt > last_gt: return matched_text if last_lt != -1: tag_content = text[last_lt:start_pos] if 'href=' in tag_content or 'src=' in tag_content: return matched_text # Extract parts number_prefix = match.group(1) or '' # "1 ", "2 ", etc. book_name = match.group(2) # Main book name chapter = match.group(3) verse_start = match.group(4) verse_end = match.group(5) # May be None # Construct full book name full_book = (number_prefix + book_name).strip() # Link to the first verse in the range return f'{matched_text}' text = re.sub(verse_pattern, verse_replace_callback, text) # Then, link person names to family tree name_to_id = get_person_name_mapping() if not name_to_id: return text # Sort names by length (longest first) to handle multi-word names correctly sorted_names = sorted(name_to_id.keys(), key=len, reverse=True) # Process each name for name_lower in sorted_names: person_id = name_to_id[name_lower] # Create a case-insensitive regex pattern with word boundaries # This will match the name but not if it's inside an HTML tag name_pattern = re.escape(name_lower) # Use a callback function to avoid replacing text inside HTML tags def replace_callback(match): matched_text = match.group(0) # Check if this match is inside an HTML tag start_pos = match.start() # Look backwards to see if we're inside a tag text_before = text[:start_pos] last_lt = text_before.rfind('<') last_gt = text_before.rfind('>') # If the last '<' is more recent than the last '>', we're inside a tag if last_lt > last_gt: return matched_text # Also check if we're inside an href or other attribute if last_lt != -1: tag_content = text[last_lt:start_pos] if 'href=' in tag_content or 'src=' in tag_content: return matched_text # Safe to link return f'{matched_text}' # Use word boundaries and case-insensitive matching pattern = r'\b' + name_pattern + r'\b' text = re.sub(pattern, replace_callback, text, flags=re.IGNORECASE) return text # Register the custom Jinja2 filter for linking person names in templates templates.env.filters['link_names'] = link_person_names_in_text def link_verse_references_in_text(text): """Automatically link verse references in text (e.g., 'Genesis 1:1', 'Hebrews 9:22')""" if not text: return text # Pattern to match verse references like "Genesis 1:1", "1 Corinthians 5:7", "Romans 4:3" # Matches: BookName Chapter:Verse or BookName Chapter:Verse-Verse pattern = r'\b((?:1|2|3)\s+)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(\d+):(\d+)(?:-(\d+))?\b' def replace_reference(match): number_prefix = match.group(1) or '' # "1 ", "2 ", "3 " or empty book_name = match.group(2) # "Corinthians", "Kings", "Genesis" chapter = match.group(3) verse_start = match.group(4) verse_end = match.group(5) # Could be None if no range # Construct full book name full_book = (number_prefix + book_name).strip() full_reference = match.group(0) # Check if this match is inside an HTML tag start_pos = match.start() text_before = text[:start_pos] last_lt = text_before.rfind('<') last_gt = text_before.rfind('>') # If inside a tag, don't replace if last_lt > last_gt: return full_reference # Also check if we're inside an href or other attribute if last_lt != -1: tag_content = text[last_lt:start_pos] if 'href=' in tag_content or 'src=' in tag_content: return full_reference # Create link to verse page url = f'/book/{full_book}/chapter/{chapter}/verse/{verse_start}' return f'{full_reference}' return re.sub(pattern, replace_reference, text) # Register the verse reference linking filter templates.env.filters['link_verses'] = link_verse_references_in_text def inject_word_markers(text, word_studies, verse_num): """Inject sidenote markers into verse text next to annotated words""" if not word_studies: return text # Process each word study for idx, study in enumerate(word_studies, 1): word = study['word'] # Create the sidenote marker HTML marker = f'{word}: {study["term"]} ({study["translit"]}). {study["note"]}' # Find and replace the word with word + marker # Use a more precise replacement to avoid replacing partial matches import re # Match the word with word boundaries, case-insensitive pattern = re.compile(r'\b(' + re.escape(word) + r')\b', re.IGNORECASE) text = pattern.sub(r'\1' + marker, text, count=1) return text templates.env.filters['inject_word_markers'] = inject_word_markers def red_letter(text, book, chapter, verse_num): """Wrap the words of Christ in red letter span tags""" from .red_letter import wrap_red_letter_text return wrap_red_letter_text(text, book, chapter, verse_num) templates.env.filters['red_letter'] = red_letter def format_numbered_lists(text): """Convert (1), (2), etc. patterns into HTML ordered lists""" import re # Pattern to find all numbered items like (1), (2), etc. item_pattern = r'\((\d+)\)\s*' # Find all numbered markers markers = list(re.finditer(item_pattern, text)) if len(markers) < 2: return text # Check if markers are sequential starting from 1 numbers = [int(m.group(1)) for m in markers] if numbers[0] != 1: return text # Find the longest sequential run starting from 1 seq_length = 1 for i in range(1, len(numbers)): if numbers[i] == seq_length + 1: seq_length += 1 else: break if seq_length < 2: return text # Use only the sequential markers markers = markers[:seq_length] # Extract content for each item list_items = [] for i, marker in enumerate(markers): start = marker.end() # After the (N) marker if i + 1 < len(markers): # Content ends where next marker begins end = markers[i + 1].start() else: # Last item - find where it ends (next sentence or end of reasonable content) # Look for a period followed by a capital letter (new sentence) or end of text remaining = text[start:] # Find the end of this list item - look for period followed by space and capital # or semicolon, but capture meaningful content end_match = re.search(r'[.;]\s+(?=[A-Z])|$', remaining) if end_match: end = start + end_match.start() + 1 # Include the period else: end = len(text) item_text = text[start:end].strip() # Clean up trailing punctuation item_text = item_text.rstrip(';,.') # Clean up trailing "and" if item_text.endswith(' and'): item_text = item_text[:-4] list_items.append(f'
  • {item_text}
  • ') # Build the HTML list html_list = '
      ' + ''.join(list_items) + '
    ' # Find where to insert the list list_start = markers[0].start() # Find where the list content ends last_marker = markers[-1] last_item_start = last_marker.end() remaining_after_last = text[last_item_start:] # Find end of last item end_match = re.search(r'[.;]\s+(?=[A-Z])|$', remaining_after_last) if end_match: list_end = last_item_start + end_match.start() + 1 else: list_end = len(text) # Replace the numbered list portion with HTML after_list = text[list_end:].strip() if after_list: result = text[:list_start] + html_list + '

    ' + after_list else: result = text[:list_start] + html_list return result templates.env.filters['format_lists'] = format_numbered_lists def get_biblical_timeline_context(): """ Load comprehensive biblical timeline data from JSON file. Returns tuple of (timeline_events, introduction, chronology_note, chronology_comparison, conclusion) """ # Load timeline data from JSON file data_dir = PathLib(__file__).parent / "data" timeline_path = data_dir / "biblical_timeline.json" with open(timeline_path, 'r', encoding='utf-8') as f: timeline_data = json.load(f) timeline_events = timeline_data.get("timeline_events", {}) introduction = timeline_data.get("introduction", "") chronology_note = timeline_data.get("chronology_note", "") chronology_comparison = timeline_data.get("chronology_comparison", []) conclusion = timeline_data.get("conclusion", "") return timeline_events, introduction, chronology_note, chronology_comparison, conclusion @app.get("/biblical-timeline", response_class=HTMLResponse) def biblical_timeline_page(request: Request): """Biblical timeline page showing major biblical events chronologically""" books = bible.get_books() timeline_events, introduction, chronology_note, chronology_comparison, conclusion = get_biblical_timeline_context() breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Resources", "url": "/resources"}, {"text": "Biblical Timeline", "url": None} ] return templates.TemplateResponse( request, "biblical_timeline.html", { "books": books, "timeline_events": timeline_events, "introduction": introduction, "chronology_note": chronology_note, "chronology_comparison": chronology_comparison, "conclusion": conclusion, "breadcrumbs": breadcrumbs, "pdf_available": WEASYPRINT_AVAILABLE } ) @app.get("/biblical-timeline/pdf") async def biblical_timeline_pdf(): """Generate PDF export for the biblical timeline.""" if not WEASYPRINT_AVAILABLE: raise HTTPException( status_code=503, detail="PDF generation is not available. WeasyPrint system libraries are not installed." ) timeline_events, introduction, chronology_note, chronology_comparison, conclusion = get_biblical_timeline_context() html_content = templates.get_template("biblical_timeline_pdf.html").render( timeline_events=timeline_events, introduction=introduction, chronology_note=chronology_note, chronology_comparison=chronology_comparison, conclusion=conclusion ) pdf_buffer = await render_html_to_pdf_async(html_content) return StreamingResponse( pdf_buffer, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=biblical-timeline.pdf"} ) def get_biblical_verses(name): """Get relevant Bible verses for a person based on their name""" verse_map = { "Adam": [ {"reference": "Genesis 2:7", "text": "And the LORD God formed man of the dust of the ground, and breathed into his nostrils the breath of life; and man became a living soul."}, {"reference": "Genesis 1:27", "text": "So God created man in his own image, in the image of God created he him; male and female created he them."} ], "Eve": [ {"reference": "Genesis 2:22", "text": "And the rib, which the LORD God had taken from man, made he a woman, and brought her unto the man."}, {"reference": "Genesis 3:20", "text": "And Adam called his wife's name Eve; because she was the mother of all living."} ], "Cain": [ {"reference": "Genesis 4:1", "text": "And Adam knew Eve his wife; and she conceived, and bare Cain, and said, I have gotten a man from the LORD."}, {"reference": "Genesis 4:8", "text": "And Cain talked with Abel his brother: and it came to pass, when they were in the field, that Cain rose up against Abel his brother, and slew him."} ], "Abel": [ {"reference": "Genesis 4:2", "text": "And she again bare his brother Abel. And Abel was a keeper of sheep, but Cain was a tiller of the ground."}, {"reference": "Genesis 4:4", "text": "And Abel, he also brought of the firstlings of his flock and of the fat thereof. And the LORD had respect unto Abel and to his offering:"} ], "Seth": [ {"reference": "Genesis 4:25", "text": "And Adam knew his wife again; and she bare a son, and called his name Seth: For God, said she, hath appointed me another seed instead of Abel, whom Cain slew."}, {"reference": "Genesis 5:3", "text": "And Adam lived an hundred and thirty years, and begat a son in his own likeness, after his image; and called his name Seth:"} ], "Enoch": [ {"reference": "Genesis 5:21", "text": "And Enoch lived sixty and five years, and begat Methuselah:"}, {"reference": "Genesis 5:24", "text": "And Enoch walked with God: and he was not; for God took him."} ], "Noah": [ {"reference": "Genesis 6:8", "text": "But Noah found grace in the eyes of the LORD."}, {"reference": "Genesis 7:1", "text": "And the LORD said unto Noah, Come thou and all thy house into the ark; for thee have I seen righteous before me in this generation."} ], "Methuselah": [ {"reference": "Genesis 5:25", "text": "And Methuselah lived an hundred eighty and seven years, and begat Lamech:"}, {"reference": "Genesis 5:27", "text": "And all the days of Methuselah were nine hundred sixty and nine years: and he died."} ] } return verse_map.get(name, []) def get_daily_verse(date_str=None): """Get the verse of the day based on a specific date (or current date if not provided)""" # Use date as seed for consistent daily verse if date_str is None: date_str = datetime.now().strftime("%Y-%m-%d") seed = int(hashlib.md5(date_str.encode()).hexdigest(), 16) % 1000000 # Featured verses for rotation featured_verses = [ ("John", 3, 16), ("Jeremiah", 29, 11), ("Philippians", 4, 13), ("Romans", 8, 28), ("Proverbs", 3, 5), ("Isaiah", 41, 10), ("Matthew", 11, 28), ("1 John", 4, 19), ("Psalms", 23, 1), ("2 Corinthians", 5, 17), ("Ephesians", 2, 8), ("Romans", 10, 9), ("1 Peter", 5, 7), ("James", 1, 5), ("Philippians", 4, 19), ("Psalms", 119, 105), ("Matthew", 6, 33), ("Romans", 12, 2), ("1 Corinthians", 13, 13), ("Galatians", 5, 22), ("Hebrews", 11, 1), ("1 Thessalonians", 5, 18), ("Psalms", 46, 1), ("Isaiah", 40, 31), ("Matthew", 5, 16), ("Romans", 15, 13), ("Colossians", 3, 23), ("1 John", 1, 9), ("Psalms", 37, 4), ("Proverbs", 27, 17) ] # Select verse based on seed verse_index = seed % len(featured_verses) book, chapter, verse = featured_verses[verse_index] verse_text = bible.get_verse_text(book, chapter, verse) if not verse_text: # Fallback to John 3:16 book, chapter, verse = "John", 3, 16 verse_text = bible.get_verse_text(book, chapter, verse) return { "book": book, "chapter": chapter, "verse": verse, "text": verse_text, "reference": f"{book} {chapter}:{verse}", "date": date_str } @app.get("/", response_class=HTMLResponse) def read_root(request: Request): books = bible.get_books() daily_verse = get_daily_verse() # Define study guide categories study_guides = { "Foundational Studies": [ { "title": "New Believer's Guide", "description": "Essential truths for new Christians", "slug": "new-believer", "verses": ["John 3:16", "Romans 10:9", "1 John 1:9", "2 Corinthians 5:17"] }, { "title": "Salvation by Grace", "description": "Understanding God's gift of salvation", "slug": "salvation", "verses": ["Ephesians 2:8-9", "Romans 3:23", "Romans 6:23", "Titus 3:5"] }, { "title": "The Gospel Message", "description": "The good news of Jesus Christ", "slug": "gospel", "verses": ["1 Corinthians 15:3-4", "Romans 1:16", "Mark 16:15", "Acts 4:12"] } ], "Character & Living": [ { "title": "Fruits of the Spirit", "description": "Developing Christian character", "slug": "fruits-spirit", "verses": ["Galatians 5:22-23", "1 Corinthians 13:4-7", "Philippians 4:8", "Colossians 3:12-14"] }, { "title": "Prayer & Faith", "description": "Growing in prayer and trust", "slug": "prayer-faith", "verses": ["Matthew 6:9-13", "1 Thessalonians 5:17", "Hebrews 11:1", "James 1:6"] }, { "title": "Christian Living", "description": "Walking as followers of Christ", "slug": "christian-living", "verses": ["Romans 12:1-2", "1 Peter 2:9", "Matthew 5:14-16", "Philippians 2:14-16"] } ], "Biblical Themes": [ { "title": "God's Love", "description": "Understanding the depth of God's love", "slug": "gods-love", "verses": ["1 John 4:8", "John 3:16", "Romans 8:38-39", "1 John 3:1"] }, { "title": "Hope & Comfort", "description": "Finding hope in difficult times", "slug": "hope-comfort", "verses": ["Romans 15:13", "2 Corinthians 1:3-4", "Psalm 23:4", "Isaiah 41:10"] }, { "title": "Wisdom & Guidance", "description": "Seeking God's wisdom for life", "slug": "wisdom-guidance", "verses": ["Proverbs 3:5-6", "James 1:5", "Psalm 119:105", "Proverbs 27:17"] } ], "Doctrinal Studies": [ { "title": "The Trinity", "description": "Understanding God as Father, Son, and Holy Spirit", "slug": "trinity", "verses": ["Matthew 28:19", "2 Corinthians 13:14", "1 Peter 1:2", "John 14:16-17"] }, { "title": "The Resurrection", "description": "Christ's victory over death and our hope", "slug": "resurrection", "verses": ["1 Corinthians 15:20-22", "Romans 6:4-5", "John 11:25-26", "1 Thessalonians 4:16-17"] }, { "title": "Heaven & Eternity", "description": "Our eternal home with God", "slug": "heaven-eternity", "verses": ["Revelation 21:1-4", "John 14:2-3", "Philippians 3:20-21", "1 Corinthians 2:9"] } ], "Family & Relationships": [ { "title": "Biblical Marriage", "description": "God's design for marriage", "slug": "biblical-marriage", "verses": ["Ephesians 5:22-33", "Genesis 2:24", "1 Corinthians 7:3-5", "Hebrews 13:4"] }, { "title": "Raising Children", "description": "Biblical principles for parenting", "slug": "raising-children", "verses": ["Proverbs 22:6", "Ephesians 6:4", "Deuteronomy 6:6-7", "Colossians 3:21"] }, { "title": "Money & Stewardship", "description": "Biblical wisdom on finances", "slug": "money-stewardship", "verses": ["Malachi 3:10", "Luke 16:10-11", "1 Timothy 6:10", "Proverbs 3:9-10"] } ] } # Process verse references to add URLs for category in study_guides.values(): for guide in category: guide['verse_refs'] = [ { 'text': verse, 'url': verse_reference_to_url(verse) or '#' } for verse in guide['verses'] ] return templates.TemplateResponse( request, "index.html", {"books": books, "daily_verse": daily_verse, "study_guides": study_guides} ) @app.get("/books", response_class=HTMLResponse) def books_page(request: Request): """Browse all books of the Bible""" books = bible.get_books() # Define book categories with types book_types = { # Old Testament 'Genesis': 'law', 'Exodus': 'law', 'Leviticus': 'law', 'Numbers': 'law', 'Deuteronomy': 'law', 'Joshua': 'historical', 'Judges': 'historical', 'Ruth': 'historical', '1 Samuel': 'historical', '2 Samuel': 'historical', '1 Kings': 'historical', '2 Kings': 'historical', '1 Chronicles': 'historical', '2 Chronicles': 'historical', 'Ezra': 'historical', 'Nehemiah': 'historical', 'Esther': 'historical', 'Job': 'wisdom', 'Psalms': 'wisdom', 'Proverbs': 'wisdom', 'Ecclesiastes': 'wisdom', 'Song of Solomon': 'wisdom', 'Isaiah': 'major-prophets', 'Jeremiah': 'major-prophets', 'Lamentations': 'major-prophets', 'Ezekiel': 'major-prophets', 'Daniel': 'major-prophets', 'Hosea': 'minor-prophets', 'Joel': 'minor-prophets', 'Amos': 'minor-prophets', 'Obadiah': 'minor-prophets', 'Jonah': 'minor-prophets', 'Micah': 'minor-prophets', 'Nahum': 'minor-prophets', 'Habakkuk': 'minor-prophets', 'Zephaniah': 'minor-prophets', 'Haggai': 'minor-prophets', 'Zechariah': 'minor-prophets', 'Malachi': 'minor-prophets', # New Testament 'Matthew': 'gospels', 'Mark': 'gospels', 'Luke': 'gospels', 'John': 'gospels', 'Acts': 'acts', 'Romans': 'pauline', '1 Corinthians': 'pauline', '2 Corinthians': 'pauline', 'Galatians': 'pauline', 'Ephesians': 'pauline', 'Philippians': 'pauline', 'Colossians': 'pauline', '1 Thessalonians': 'pauline', '2 Thessalonians': 'pauline', '1 Timothy': 'pauline', '2 Timothy': 'pauline', 'Titus': 'pauline', 'Philemon': 'pauline', 'Hebrews': 'general', 'James': 'general', '1 Peter': 'general', '2 Peter': 'general', '1 John': 'general', '2 John': 'general', '3 John': 'general', 'Jude': 'general', 'Revelation': 'apocalyptic' } # Organize books by testament old_testament_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 = [ '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' ] # Get chapter counts for each book def get_chapter_count(book_name): chapters = bible.get_chapters_for_book(book_name) return len(chapters) old_testament = [ { 'name': book, 'chapters': get_chapter_count(book), 'available': book in books, 'type': book_types.get(book, '') } for book in old_testament_books ] new_testament = [ { 'name': book, 'chapters': get_chapter_count(book), 'available': book in books, 'type': book_types.get(book, '') } for book in new_testament_books ] breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Books", "url": None} ] return templates.TemplateResponse( request, "books.html", { "old_testament": old_testament, "new_testament": new_testament, "books": books, "breadcrumbs": breadcrumbs } ) @app.get("/reading-plans", response_class=HTMLResponse) def reading_plans_page(request: Request): """Browse Bible reading plans""" books = bible.get_books() plans = get_plan_summary() breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Reading Plans", "url": None} ] return templates.TemplateResponse( request, "reading_plans.html", { "plans": plans, "books": books, "breadcrumbs": breadcrumbs } ) @app.get("/reading-plans/{plan_id}", response_class=HTMLResponse) def reading_plan_detail(request: Request, plan_id: str): """View a specific reading plan""" books = bible.get_books() plan = get_plan(plan_id) if not plan: raise HTTPException(status_code=404, detail="Reading plan not found") breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Reading Plans", "url": "/reading-plans"}, {"text": plan["name"], "url": None} ] return templates.TemplateResponse( request, "reading_plan_detail.html", { "plan": plan, "plan_id": plan_id, "books": books, "breadcrumbs": breadcrumbs, "pdf_available": WEASYPRINT_AVAILABLE, "pdf_url": f"/reading-plans/{plan_id}/pdf" if WEASYPRINT_AVAILABLE else None } ) @app.get("/reading-plans/{plan_id}/pdf") async def reading_plan_pdf(plan_id: str): """Generate a PDF export for a reading plan.""" if not WEASYPRINT_AVAILABLE: raise HTTPException( status_code=503, detail="PDF generation is not available. WeasyPrint system libraries are not installed." ) plan = get_plan(plan_id) if not plan: raise HTTPException(status_code=404, detail="Reading plan not found") html_content = templates.get_template("reading_plan_pdf.html").render(plan=plan) pdf_buffer = await render_html_to_pdf_async(html_content) filename = f"reading-plan-{plan_id}.pdf" return StreamingResponse( pdf_buffer, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"} ) @app.get("/topics", response_class=HTMLResponse) def topics_page(request: Request): """Browse topical index of Bible themes""" books = bible.get_books() topics = get_all_topics() breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Topics", "url": None} ] return templates.TemplateResponse( request, "topics.html", { "topics": topics, "books": books, "breadcrumbs": breadcrumbs, "pdf_available": WEASYPRINT_AVAILABLE } ) @app.get("/resources", response_class=HTMLResponse) def resources_page(request: Request): """Browse all theological resources""" books = bible.get_books() # Organize resources into categories resources = { "People": [ { "name": "Biblical Prophets", "url": "/biblical-prophets", "description": "Explore the prophetic ministry throughout Scripture, from Isaiah to Malachi", "count": "9 prophets" }, { "name": "The Twelve Apostles", "url": "/the-twelve-apostles", "description": "The twelve disciples chosen by Jesus to be witnesses of His ministry", "count": "12 apostles" }, { "name": "Women of the Bible", "url": "/women-of-the-bible", "description": "Notable women of Scripture and their significance in redemptive history", "count": "12 women" } ], "Theology": [ { "name": "Biblical Angels", "url": "/biblical-angels", "description": "Angelic beings mentioned in Scripture, including Michael, Gabriel, and the heavenly host", "count": "12 entries" }, { "name": "The Tetragrammaton", "url": "/tetragrammaton", "description": "The sacred four-letter name of God (YHWH) and its profound significance", "count": "Deep dive" }, { "name": "Names of God", "url": "/names-of-god", "description": "The revelation of God's names throughout Scripture and their meanings", "count": "14 names" }, { "name": "Parables of Jesus", "url": "/parables", "description": "The parables spoken by Christ to illustrate spiritual truths", "count": "11 parables" }, { "name": "Miracles of Jesus", "url": "/miracles-of-jesus", "description": "Signs and wonders manifesting divine authority over nature, disease, demons, and death", "count": "35+ miracles" }, { "name": "I Am Statements", "url": "/i-am-statements", "description": "The seven 'I Am' statements of Jesus in John's Gospel revealing His divine nature", "count": "7 statements" }, { "name": "The Beatitudes", "url": "/beatitudes", "description": "The blessings proclaimed by Jesus in the Sermon on the Mount", "count": "8 beatitudes" }, { "name": "Ten Commandments", "url": "/ten-commandments", "description": "The moral law given by God to Moses on Mount Sinai", "count": "10 commandments" }, { "name": "Armor of God", "url": "/armor-of-god", "description": "The spiritual equipment for warfare described in Ephesians 6", "count": "7 pieces" }, { "name": "Prayers of the Bible", "url": "/prayers-of-the-bible", "description": "Sacred prayers from the Psalms, Jesus, Paul, and the early church", "count": "20+ prayers" }, { "name": "Biblical Covenants", "url": "/biblical-covenants", "description": "Divine covenants established between God and His people", "count": "7 covenants" }, { "name": "Fruits of the Spirit", "url": "/fruits-of-the-spirit", "description": "The nine graces of Galatians 5:22-23 manifested in believers through the Holy Spirit", "count": "9 fruits" } ], "Systematic Theology": [ { "name": "The Trinity", "url": "/trinity", "description": "The mystery of God revealed as Father, Son, and Holy Spirit—three Persons, one God", "count": "4 categories" }, { "name": "Christology", "url": "/christology", "description": "The Person and work of Jesus Christ—His deity, humanity, and offices", "count": "4 categories" }, { "name": "Pneumatology", "url": "/pneumatology", "description": "The doctrine of the Holy Spirit—His Person, deity, and work in believers", "count": "4 categories" }, { "name": "Soteriology", "url": "/soteriology", "description": "The doctrine of salvation—from election to glorification", "count": "5 categories" }, { "name": "Ecclesiology", "url": "/ecclesiology", "description": "The doctrine of the Church—its nature, mission, and governance", "count": "4 categories" }, { "name": "Eschatology", "url": "/eschatology", "description": "The doctrine of last things—Christ's return, judgment, and eternal state", "count": "5 categories" }, { "name": "The Kingdom of God", "url": "/kingdom-of-god", "description": "God's sovereign reign inaugurated in Christ and consummated at His return", "count": "5 categories" }, { "name": "Types and Shadows", "url": "/types-and-shadows", "description": "Old Testament persons, events, and institutions that prefigure Christ", "count": "5 categories" }, { "name": "Messianic Prophecies", "url": "/messianic-prophecies", "description": "Old Testament prophecies fulfilled in Jesus Christ", "count": "5 categories" }, { "name": "The Blood in Scripture", "url": "/blood-in-scripture", "description": "The theology of blood, sacrifice, and redemption throughout Scripture", "count": "5 categories" }, { "name": "Names and Titles of Christ", "url": "/names-of-christ", "description": "The names and titles ascribed to Jesus revealing His Person and work", "count": "5 categories" }, { "name": "Spirits & Demons", "url": "/spirits-and-demons", "description": "Biblical demonology—Satan, evil spirits, Legion, and spiritual warfare", "count": "7 categories" }, { "name": "Personifications", "url": "/personifications", "description": "Abstract concepts given human form—Wisdom, Folly, Death, Sin, and more", "count": "6 categories" }, { "name": "Bibliology", "url": "/bibliology", "description": "The Doctrine of Scripture—inspiration, authority, sufficiency, and preservation", "count": "4 categories" }, { "name": "Theology Proper", "url": "/theology-proper", "description": "The Attributes of God—His incommunicable and communicable perfections", "count": "4 categories" }, { "name": "Anthropology", "url": "/anthropology", "description": "The Doctrine of Man—creation, constitution, and condition of humanity", "count": "4 categories" }, { "name": "Hamartiology", "url": "/hamartiology", "description": "The Doctrine of Sin—its origin, nature, transmission, and consequences", "count": "4 categories" }, { "name": "Providence", "url": "/providence", "description": "Divine Providence—God's preservation, governance, and concurrence in all things", "count": "4 categories" }, { "name": "Grace", "url": "/grace", "description": "The Doctrine of Grace—common grace, effectual grace, election, and perseverance", "count": "4 categories" }, { "name": "Justification", "url": "/justification", "description": "The Doctrine of Justification—declared righteous through faith in Christ alone", "count": "4 categories" }, { "name": "Sanctification", "url": "/sanctification", "description": "The Doctrine of Sanctification—progressive holiness through the Spirit", "count": "4 categories" }, { "name": "Law and Gospel", "url": "/law-and-gospel", "description": "The distinction between Law and Gospel—God's demands and His gracious provision", "count": "4 categories" }, { "name": "Worship", "url": "/worship", "description": "The Doctrine of Worship—regulative principle, elements, and the heart of worship", "count": "4 categories" } ], "History & Culture": [ { "name": "Biblical Festivals", "url": "/biblical-festivals", "description": "The appointed feasts and holy days ordained in the Law of Moses", "count": "7 festivals" }, { "name": "Biblical Geography", "url": "/biblical-maps", "description": "Locations mentioned in Scripture and their historical significance", "count": "Maps & places" }, { "name": "Biblical Timeline", "url": "/biblical-timeline", "description": "Chronological overview of biblical events from Creation to Revelation", "count": "Timeline" }, { "name": "Genealogies", "url": "/family-tree", "description": "Family trees and lineages traced through Scripture", "count": "Family trees" } ], "Study Tools": [ { "name": "Study Guides", "url": "/study-guides", "description": "In-depth guides for studying biblical books, themes, and doctrines", "count": "Multiple guides" } ] } breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Resources", "url": None} ] return templates.TemplateResponse( request, "resources.html", { "resources": resources, "books": books, "breadcrumbs": breadcrumbs } ) @app.get("/topics/{topic_name}", response_class=HTMLResponse) def topic_detail(request: Request, topic_name: str): """View verses for a specific topic""" books = bible.get_books() topic = get_topic(topic_name) if not topic: raise HTTPException(status_code=404, detail="Topic not found") breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Topics", "url": "/topics"}, {"text": topic_name, "url": None} ] return templates.TemplateResponse( request, "topic_detail.html", { "topic": topic, "topic_name": topic_name, "books": books, "breadcrumbs": breadcrumbs, "pdf_available": WEASYPRINT_AVAILABLE, "pdf_url": f"/topics/{topic_name}/pdf" if WEASYPRINT_AVAILABLE else None } ) @app.get("/topics/{topic_name}/pdf") async def topic_detail_pdf(topic_name: str): """Generate a PDF export for a topic detail page.""" if not WEASYPRINT_AVAILABLE: raise HTTPException( status_code=503, detail="PDF generation is not available. WeasyPrint system libraries are not installed." ) topic = get_topic(topic_name) if not topic: raise HTTPException(status_code=404, detail="Topic not found") html_content = templates.get_template("topic_pdf.html").render( topic=topic, topic_name=topic_name, ) pdf_buffer = await render_html_to_pdf_async(html_content) filename = f"{topic_name}.pdf" return StreamingResponse( pdf_buffer, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"} ) @app.get("/book/{book}", response_class=HTMLResponse) def read_book(request: Request, book: str): # Redirect book name variations to canonical form canonical_name = normalize_book_name(book) if canonical_name: return RedirectResponse(url=f"/book/{canonical_name}", status_code=301) books = bible.get_books() chapters = bible.get_chapters_for_book(book) if not chapters: raise HTTPException( status_code=404, detail=f"The book '{book}' was not found. Please check the spelling or browse all available books." ) # Generate commentary data for the book page commentary_data = generate_book_commentary(book, chapters) # Calculate popularity scores for each chapter chapter_popularity = {} chapter_explanations = {} for chapter in chapters: chapter_popularity[chapter] = get_chapter_popularity_score(book, chapter) chapter_explanations[chapter] = get_chapter_popularity_explanation(book, chapter) # Get book introduction data if available book_intro = get_book_data(book) if has_book_data(book) else None # Build breadcrumbs breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Books", "url": "/books"}, {"text": book, "url": None} ] return templates.TemplateResponse( request, "book.html", { "book": book, "chapters": chapters, "books": books, "chapter_popularity": chapter_popularity, "chapter_explanations": chapter_explanations, "breadcrumbs": breadcrumbs, "current_book": book, "pdf_available": WEASYPRINT_AVAILABLE, "book_intro": book_intro, **commentary_data }, ) @app.get("/book/{book}/pdf") async def book_pdf(request: Request, book: str): """Generate a PDF export for an entire Bible book.""" if not WEASYPRINT_AVAILABLE: raise HTTPException( status_code=503, detail="PDF generation is not available. WeasyPrint system libraries are not installed." ) canonical_name = normalize_book_name(book) if canonical_name: return RedirectResponse(url=f"/book/{canonical_name}/pdf", status_code=301) chapters = bible.get_chapters_for_book(book) if not chapters: raise HTTPException( status_code=404, detail=f"The book '{book}' was not found. Please check the spelling or browse all available books." ) chapters_data = [] total_verses = 0 for chapter_num in chapters: verses = bible.get_verses_by_book_chapter(book, chapter_num) if not verses: continue total_verses += len(verses) chapters_data.append({ "chapter": chapter_num, "verses": verses }) if not chapters_data: raise HTTPException( status_code=404, detail=f"No verses found for the book '{book}'." ) # Get book introduction data if available book_intro = get_book_data(book) if has_book_data(book) else None html_content = templates.get_template("book_pdf.html").render( book=book, chapters=chapters_data, chapter_count=len(chapters_data), verse_count=total_verses, book_intro=book_intro, ) pdf_buffer = await render_html_to_pdf_async(html_content) filename = f"{create_slug(book)}.pdf" return StreamingResponse( pdf_buffer, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"} ) @app.get("/book/{book}/commentary") def book_commentary_redirect(book: str): """Redirect old book commentary URLs to book page""" return RedirectResponse(url=f"/book/{book}", status_code=301) @app.get("/book/{book}/{chapter}") def redirect_chapter_legacy(book: str, chapter: int): """Redirect legacy chapter URLs to correct format""" return RedirectResponse(url=f"/book/{book}/chapter/{chapter}", status_code=301) @app.get("/book/{book}/chapter/{chapter}", response_class=HTMLResponse) def read_chapter(request: Request, book: str, chapter: int): # Redirect book name variations to canonical form canonical_name = normalize_book_name(book) if canonical_name: return RedirectResponse(url=f"/book/{canonical_name}/chapter/{chapter}", status_code=301) books = bible.get_books() verses = bible.get_verses_by_book_chapter(book, chapter) chapters = bible.get_chapters_for_book(book) if not verses: # Check if the book exists first if not chapters: raise HTTPException( status_code=404, detail=f"The book '{book}' was not found. Please check the spelling or browse all available books." ) else: raise HTTPException( status_code=404, detail=f"Chapter {chapter} of {book} was not found. This book has {len(chapters)} chapters." ) # Generate AI commentary for the chapter commentaries = {} for verse in verses: commentary = generate_commentary(book, chapter, verse) # Add word study sidenotes commentary['word_studies'] = generate_word_study_sidenotes(verse.text, book, chapter, verse.verse) # Add cross-references with proper URLs cross_refs = get_cross_references(book, chapter, verse.verse) commentary['cross_references'] = [ { 'text': ref['ref'], 'url': f"/book/{ref['ref'].rsplit(' ', 1)[0]}/chapter/{ref['ref'].rsplit(' ', 1)[1].split(':')[0]}/verse/{ref['ref'].rsplit(' ', 1)[1].split(':')[1]}" if ' ' in ref['ref'] and ':' in ref['ref'] else '#', 'context': ref['note'] } for ref in cross_refs ] commentaries[verse.verse] = commentary # Generate chapter overview chapter_overview = generate_chapter_overview(book, chapter, verses) # Build breadcrumbs breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Books", "url": "/books"}, {"text": book, "url": f"/book/{book}"}, {"text": f"Chapter {chapter}", "url": None} ] return templates.TemplateResponse( request, "chapter.html", { "book": book, "chapter": chapter, "verses": verses, "books": books, "chapters": chapters, "commentaries": commentaries, "chapter_overview": chapter_overview, "breadcrumbs": breadcrumbs, "current_book": book, "current_chapter": chapter, "pdf_available": WEASYPRINT_AVAILABLE } ) @app.get("/book/{book}/chapter/{chapter}/pdf") async def chapter_pdf(request: Request, book: str, chapter: int): """Generate a PDF export for a specific Bible chapter.""" if not WEASYPRINT_AVAILABLE: raise HTTPException( status_code=503, detail="PDF generation is not available. WeasyPrint system libraries are not installed." ) canonical_name = normalize_book_name(book) if canonical_name: return RedirectResponse(url=f"/book/{canonical_name}/chapter/{chapter}/pdf", status_code=301) verses = bible.get_verses_by_book_chapter(book, chapter) chapters = bible.get_chapters_for_book(book) if not verses: if not chapters: raise HTTPException( status_code=404, detail=f"The book '{book}' was not found. Please check the spelling or browse all available books." ) raise HTTPException( status_code=404, detail=f"Chapter {chapter} of {book} was not found. This book has {len(chapters)} chapters." ) html_content = templates.get_template("chapter_pdf.html").render( book=book, chapter=chapter, verses=verses, verse_count=len(verses), ) pdf_buffer = await render_html_to_pdf_async(html_content) filename = f"{create_slug(book)}-chapter-{chapter}.pdf" return StreamingResponse( pdf_buffer, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"} ) @app.get("/book/{book}/chapter/{chapter}/verse/{verse_num}", response_class=HTMLResponse) def read_verse(request: Request, book: str, chapter: int, verse_num: int): """Display a single verse with detailed commentary""" # Redirect book name variations to canonical form canonical_name = normalize_book_name(book) if canonical_name: return RedirectResponse(url=f"/book/{canonical_name}/chapter/{chapter}/verse/{verse_num}", status_code=301) books = bible.get_books() verses = bible.get_verses_by_book_chapter(book, chapter) chapters = bible.get_chapters_for_book(book) if not verses: # Check if the book exists first if not chapters: raise HTTPException( status_code=404, detail=f"The book '{book}' was not found. Please check the spelling or browse all available books." ) else: raise HTTPException( status_code=404, detail=f"Chapter {chapter} of {book} was not found. This book has {len(chapters)} chapters." ) # Find the specific verse verse = None for v in verses: if v.verse == verse_num: verse = v break if not verse: raise HTTPException( status_code=404, detail=f"Verse {verse_num} not found in {book} {chapter}. This chapter has {len(verses)} verses." ) # Generate commentary for this verse try: commentary = generate_commentary(book, chapter, verse) except Exception as e: # Log the error but don't fail the request print(f"Error generating commentary for {book} {chapter}:{verse_num}: {e}") commentary = None # Get cross-references for this verse cross_refs = get_cross_references(book, chapter, verse_num) # Check if interlinear data is available and load it has_interlinear = has_interlinear_data(book, chapter, verse_num) interlinear_words = get_interlinear_data(book, chapter, verse_num) if has_interlinear else None # Get related content for internal linking related_content = get_related_content(book, chapter, verse_num) # Build breadcrumbs breadcrumbs = [ {"text": "Home", "url": "/"}, {"text": "Books", "url": "/books"}, {"text": book, "url": f"/book/{book}"}, {"text": f"Chapter {chapter}", "url": f"/book/{book}/chapter/{chapter}"}, {"text": f"Verse {verse_num}", "url": None} ] return templates.TemplateResponse( request, "verse.html", { "book": book, "chapter": chapter, "verse_num": verse_num, "verse_text": verse.text, "commentary": commentary, "cross_references": cross_refs, "total_verses": len(verses), "books": books, "chapters": chapters, "breadcrumbs": breadcrumbs, "current_book": book, "current_chapter": chapter, "current_verse": verse_num, "has_interlinear": has_interlinear, "interlinear_words": interlinear_words, "related_content": related_content } )