mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
1a4f6150e4
Updated the red letter functionality to wrap only the specific words Jesus spoke within each verse, rather than entire verses. This provides more accurate red letter highlighting similar to traditional red letter Bibles. Changes: - Modified red_letter.py to search for and wrap only Christ's words - Updated JSON structure to store actual quoted text - Changed filter to handle both full verses and partial quotes - Improved accuracy of red letter highlighting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2319 lines
85 KiB
Python
2319 lines
85 KiB
Python
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 <p> tags that mistune might add
|
|
html = _inline_markdown(text).strip()
|
|
if html.startswith('<p>') and html.endswith('</p>'):
|
|
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'<span class="highlight">{m.group()}</span>',
|
|
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'<a href="/book/{full_book}/chapter/{chapter}/verse/{verse_start}">{matched_text}</a>'
|
|
|
|
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'<a href="/family-tree/person/{person_id}">{matched_text}</a>'
|
|
|
|
# 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'<a href="{url}">{full_reference}</a>'
|
|
|
|
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'<label for="sn-{verse_num}-word-{idx}" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-{verse_num}-word-{idx}" class="margin-toggle"/><span class="sidenote"><strong>{word}:</strong> {study["term"]} (<em>{study["translit"]}</em>). {study["note"]}</span>'
|
|
|
|
# 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'<li>{item_text}</li>')
|
|
|
|
# Build the HTML list
|
|
html_list = '<ol>' + ''.join(list_items) + '</ol>'
|
|
|
|
# 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 + '</p><p>' + 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
|
|
}
|
|
)
|