Files
kjvstudy.org/kjvstudy_org/routes/misc.py
T
kennethreitz 108667f531 Add standalone build support with bundled Python server
- Make PIL import conditional in og_image.py for builds without Pillow
- Update OG image routes to fall back to default image when PIL unavailable
- Update Tauri to find and use bundled PyInstaller executable
- Use transparent titlebar for full-width modern appearance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 15:01:06 -05:00

604 lines
19 KiB
Python

"""Miscellaneous routes - search, interlinear, random verse, verse of the day, red letter, OG images."""
import hashlib
import random
import re
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Query, Request, Path
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from ..kjv import bible
from ..red_letter import load_red_letter_verses
from ..utils.search import perform_full_text_search
from ..og_image import get_cached_or_generate, PIL_AVAILABLE, DEFAULT_OG_IMAGE
router = APIRouter()
templates = None
# Will be set by init_search_family_tree()
_search_family_tree_fn = None
def init_templates(t: Jinja2Templates):
"""Initialize templates for misc routes."""
global templates
templates = t
def init_search_family_tree(fn):
"""Initialize the search_family_tree function from server.py."""
global _search_family_tree_fn
_search_family_tree_fn = fn
# =============================================================================
# Helper Functions
# =============================================================================
def _load_featured_verses():
"""Load featured verses from JSON file."""
import json
from pathlib import Path
data_file = Path(__file__).parent.parent / "data" / "featured_verses.json"
if data_file.exists():
with open(data_file, "r", encoding="utf-8") as f:
return json.load(f).get("verses", [])
return []
def get_daily_verse(date_str=None):
"""Get the verse of the day based on a specific date (or current date if not provided).
Uses calendar-based selection: verses are organized by month theme, so
January dates get January-themed verses, December dates get Advent verses, etc.
"""
if date_str is None:
date_obj = datetime.now()
date_str = date_obj.strftime("%Y-%m-%d")
else:
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
# Load featured verses from JSON file
featured_verses = _load_featured_verses()
if not featured_verses:
# Fallback if file not found
featured_verses = [{"book": "John", "chapter": 3, "verse": 16}]
# Use day of year for calendar-based selection (1-365/366)
# This ensures January verses appear in January, December verses in December, etc.
day_of_year = date_obj.timetuple().tm_yday # 1-366
verse_index = (day_of_year - 1) % len(featured_verses) # 0-364
verse_data = featured_verses[verse_index]
book = verse_data["book"]
chapter = verse_data["chapter"]
verse = verse_data["verse"]
devotional = verse_data.get("devotional")
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)
devotional = None
return {
"book": book,
"chapter": chapter,
"verse": verse,
"text": verse_text,
"reference": f"{book} {chapter}:{verse}",
"date": date_str,
"devotional": devotional
}
# =============================================================================
# Routes
# =============================================================================
def parse_strongs_number(query: str) -> str | None:
"""Parse a Strong's number query and return the redirect URL, or None."""
if not query:
return None
query = query.strip()
# Match H1, H1234, G1, G5678, etc. (case insensitive)
match = re.match(r'^([HhGg])(\d+)$', query)
if match:
prefix = match.group(1).upper()
number = match.group(2)
return f'/strongs/{prefix}{number}'
return None
@router.get("/search", response_class=HTMLResponse)
async def search_page(request: Request, q: str = Query(None, description="Search query")):
"""Search page with results (includes Bible verses and family tree)"""
# Check if query is a Strong's number - redirect if so
if q:
strongs_url = parse_strongs_number(q)
if strongs_url:
return RedirectResponse(url=strongs_url, status_code=302)
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)
if _search_family_tree_fn:
family_tree_results = _search_family_tree_fn(q.strip(), limit=5)
breadcrumbs = [
{"text": "Home", "url": "/"},
{"text": "Search", "url": None}
]
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,
"breadcrumbs": breadcrumbs
}
)
@router.get("/interlinear", response_class=HTMLResponse)
async 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#interlinear", "note": "God's love for the world"},
{"reference": "Genesis 1:1", "url": "/book/Genesis/chapter/1/verse/1#interlinear", "note": "In the beginning"},
{"reference": "Psalm 23:1", "url": "/book/Psalms/chapter/23/verse/1#interlinear", "note": "The Lord is my shepherd"},
{"reference": "Romans 8:28", "url": "/book/Romans/chapter/8/verse/28#interlinear", "note": "All things work together for good"},
{"reference": "Matthew 28:19", "url": "/book/Matthew/chapter/28/verse/19#interlinear", "note": "The Great Commission"},
{"reference": "1 Corinthians 13:4", "url": "/book/1 Corinthians/chapter/13/verse/4#interlinear", "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
}
)
@router.get("/random-verse")
async 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 with cache control headers to ensure fresh random verse each time
response = RedirectResponse(url=f"/book/{book}/chapter/{chapter}/verse/{verse.verse}", status_code=302)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@router.get("/verse-of-the-day")
async def verse_of_the_day_redirect():
"""Redirect to today's verse of the day (prevents caching, allows bookmarking)."""
today_str = datetime.now().strftime("%Y-%m-%d")
return RedirectResponse(url=f"/verse-of-the-day/{today_str}", status_code=302)
@router.get("/verse-of-the-day/{date}", response_class=HTMLResponse)
async def verse_of_the_day_page(request: Request, date: str):
"""Verse of the day page for a specific date."""
books = bible.get_books()
# Parse the date
try:
current_date = datetime.strptime(date, "%Y-%m-%d")
except ValueError:
# Invalid date format, redirect to today
return RedirectResponse(url="/verse-of-the-day", status_code=302)
date_str = current_date.strftime("%Y-%m-%d")
daily_verse = get_daily_verse(date_str)
# Calculate prev/next dates for navigation
prev_date = (current_date - timedelta(days=1)).strftime("%Y-%m-%d")
next_date = (current_date + timedelta(days=1)).strftime("%Y-%m-%d")
today_str = datetime.now().strftime("%Y-%m-%d")
# Generate past 30 days of verses (from the viewed date)
past_verses = []
for i in range(1, 31): # Past 30 days (not including current)
past_date = current_date - timedelta(days=i)
past_date_str = past_date.strftime("%Y-%m-%d")
verse = get_daily_verse(past_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,
"prev_date": prev_date,
"next_date": next_date,
"is_today": date_str == today_str
}
)
@router.get("/stars", response_class=HTMLResponse)
async def stars_page(request: Request):
"""Stars page - displays user's saved starred pages from localStorage"""
books = bible.get_books()
breadcrumbs = [
{"text": "Home", "url": "/"},
{"text": "Stars", "url": None}
]
return templates.TemplateResponse(
request,
"stars.html",
{
"books": books,
"breadcrumbs": breadcrumbs
}
)
@router.get("/red-letter", response_class=HTMLResponse)
async def red_letter_page(
request: Request,
book: Optional[str] = Query(None, description="Filter by book"),
page: int = Query(1, ge=1, description="Page number")
):
"""Red Letter Edition - Words of Christ page"""
books = bible.get_books()
red_letter_data = load_red_letter_verses()
# Build list of all red letter verses
all_verses = []
by_book = {}
for verse_ref, christ_words in red_letter_data.items():
# Parse the reference (format: "Book Chapter:Verse")
parts = verse_ref.rsplit(' ', 1)
if len(parts) != 2:
continue
book_name = parts[0]
chapter_verse = parts[1].split(':')
if len(chapter_verse) != 2:
continue
try:
chapter_num = int(chapter_verse[0])
verse_num = int(chapter_verse[1])
except ValueError:
continue
# Count by book
by_book[book_name] = by_book.get(book_name, 0) + 1
# Apply book filter if specified
if book and book_name != book:
continue
# Get the verse text
verse_text = bible.get_verse_text(book_name, chapter_num, verse_num)
if not verse_text:
continue
all_verses.append({
"reference": verse_ref,
"book": book_name,
"chapter": chapter_num,
"verse": verse_num,
"text": verse_text,
"christ_words": christ_words,
"is_full_verse": christ_words == "full"
})
# Sort by book order, then chapter, then verse
book_order = {b: i for i, b in enumerate(books)}
all_verses.sort(key=lambda v: (book_order.get(v["book"], 999), v["chapter"], v["verse"]))
# Pagination
per_page = 50
total = len(all_verses)
total_pages = (total + per_page - 1) // per_page
page = min(page, total_pages) if total_pages > 0 else 1
offset = (page - 1) * per_page
verses = all_verses[offset:offset + per_page]
# Stats
total_all = len(red_letter_data)
full_verses = sum(1 for v in red_letter_data.values() if v == "full")
partial_verses = total_all - full_verses
# Sort books by count for sidebar
books_with_counts = sorted(by_book.items(), key=lambda x: x[1], reverse=True)
breadcrumbs = [
{"text": "Home", "url": "/"},
{"text": "Red Letter", "url": "/red-letter"}
]
if book:
breadcrumbs.append({"text": book, "url": None})
return templates.TemplateResponse(
request,
"red_letter.html",
{
"books": books,
"verses": verses,
"total": total,
"total_all": total_all,
"full_verses": full_verses,
"partial_verses": partial_verses,
"books_with_counts": books_with_counts,
"selected_book": book,
"page": page,
"total_pages": total_pages,
"per_page": per_page,
"breadcrumbs": breadcrumbs
}
)
# =============================================================================
# Dynamic OG Image Generation
# =============================================================================
async def _get_default_og_image():
"""Return the default OG image when PIL is unavailable or generation fails."""
import asyncio
content = await asyncio.to_thread(DEFAULT_OG_IMAGE.read_bytes)
return Response(
content=content,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)
@router.get("/og/verse/{book}/{chapter}/{verse}.png", response_class=Response)
async def og_image_verse(
book: str = Path(..., description="Book name"),
chapter: int = Path(..., description="Chapter number"),
verse: int = Path(..., description="Verse number")
):
"""Generate OG image for a specific verse."""
import asyncio
verse_text = bible.get_verse_text(book, chapter, verse)
if not verse_text:
# Return default image if verse not found
from pathlib import Path as PathLib
default_path = PathLib(__file__).parent.parent / "static" / "og-image.png"
content = await asyncio.to_thread(default_path.read_bytes)
return Response(content=content, media_type="image/png")
title = f"{book} {chapter}:{verse}"
cache_key = f"verse:{book}:{chapter}:{verse}"
# Run CPU-bound image generation in thread pool to avoid blocking
image_bytes = await asyncio.to_thread(
get_cached_or_generate,
cache_key=cache_key,
title=title,
subtitle="King James Version",
verse_text=verse_text,
page_type="verse"
)
if image_bytes is None:
return await _get_default_og_image()
return Response(
content=image_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)
@router.get("/og/chapter/{book}/{chapter}.png", response_class=Response)
async def og_image_chapter(
book: str = Path(..., description="Book name"),
chapter: int = Path(..., description="Chapter number")
):
"""Generate OG image for a chapter."""
import asyncio
# Get first verse as preview
verse_text = bible.get_verse_text(book, chapter, 1)
title = f"{book} {chapter}"
cache_key = f"chapter:{book}:{chapter}"
image_bytes = await asyncio.to_thread(
get_cached_or_generate,
cache_key=cache_key,
title=title,
subtitle="King James Version",
verse_text=verse_text[:150] + "..." if verse_text and len(verse_text) > 150 else verse_text,
page_type="chapter"
)
if image_bytes is None:
return await _get_default_og_image()
return Response(
content=image_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)
@router.get("/og/book/{book}.png", response_class=Response)
async def og_image_book(book: str = Path(..., description="Book name")):
"""Generate OG image for a book."""
import asyncio
title = book
cache_key = f"book:{book}"
image_bytes = await asyncio.to_thread(
get_cached_or_generate,
cache_key=cache_key,
title=title,
subtitle="King James Version Bible",
page_type="book"
)
if image_bytes is None:
return await _get_default_og_image()
return Response(
content=image_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)
@router.get("/og/topic/{topic}.png", response_class=Response)
async def og_image_topic(topic: str = Path(..., description="Topic name")):
"""Generate OG image for a topic."""
import asyncio
from urllib.parse import unquote
topic_name = unquote(topic)
title = topic_name
cache_key = f"topic:{topic_name}"
image_bytes = await asyncio.to_thread(
get_cached_or_generate,
cache_key=cache_key,
title=title,
subtitle="Topical Bible Study",
page_type="topic"
)
if image_bytes is None:
return await _get_default_og_image()
return Response(
content=image_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)
@router.get("/og/story/{slug}.png", response_class=Response)
async def og_image_story(slug: str = Path(..., description="Story slug")):
"""Generate OG image for a Bible story."""
import asyncio
import json
from pathlib import Path as PathLib
# Load story data to get title
stories_file = PathLib(__file__).parent.parent / "data" / "stories.json"
title = slug.replace("-", " ").title() # Fallback
if stories_file.exists():
with open(stories_file, "r", encoding="utf-8") as f:
stories_data = json.load(f)
for story in stories_data.get("stories", []):
if story.get("slug") == slug:
title = story.get("title", title)
break
cache_key = f"story:{slug}"
image_bytes = await asyncio.to_thread(
get_cached_or_generate,
cache_key=cache_key,
title=title,
subtitle="Bible Stories",
page_type="story"
)
if image_bytes is None:
return await _get_default_og_image()
return Response(
content=image_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)
@router.get("/og/guide/{slug}.png", response_class=Response)
async def og_image_guide(slug: str = Path(..., description="Study guide slug")):
"""Generate OG image for a study guide."""
import asyncio
title = slug.replace("-", " ").title() # Fallback title
cache_key = f"guide:{slug}"
image_bytes = await asyncio.to_thread(
get_cached_or_generate,
cache_key=cache_key,
title=title,
subtitle="Bible Study Guide",
page_type="guide"
)
if image_bytes is None:
return await _get_default_og_image()
return Response(
content=image_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"}
)