mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
Add Related Resources section to chapter pages with stories and topics
- Add related_content to chapter page route with topics, people, resources, and stories - Add Bible stories matching based on verse references in story data - Improve topic matching to use actual verse references instead of hardcoded book list - Display Related Resources section at bottom of chapter and verse pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
"""Bible routes - book, chapter, verse, and interlinear views."""
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ..kjv import bible
|
||||
from ..cross_references import get_cross_references
|
||||
from ..interlinear_loader import get_interlinear_data, has_interlinear_data
|
||||
from ..books import get_book_data, has_book_data
|
||||
from ..utils.books import normalize_book_name, OT_BOOKS
|
||||
from ..utils.helpers import create_slug, get_related_content, get_chapter_popularity_score, get_chapter_popularity_explanation
|
||||
from ..utils.pdf import WEASYPRINT_AVAILABLE, render_html_to_pdf_async
|
||||
|
||||
router = APIRouter()
|
||||
templates = None
|
||||
|
||||
|
||||
def init_bible_templates(t: Jinja2Templates):
|
||||
"""Initialize templates for Bible routes."""
|
||||
global templates
|
||||
templates = t
|
||||
|
||||
|
||||
# Import these from commentary route to avoid circular imports
|
||||
generate_commentary = None
|
||||
generate_chapter_overview = None
|
||||
generate_book_commentary = None
|
||||
generate_word_study_sidenotes = None
|
||||
|
||||
|
||||
def init_commentary_functions(commentary_func, chapter_overview_func, book_commentary_func, word_study_func):
|
||||
"""Initialize commentary functions."""
|
||||
global generate_commentary, generate_chapter_overview, generate_book_commentary, generate_word_study_sidenotes
|
||||
generate_commentary = commentary_func
|
||||
generate_chapter_overview = chapter_overview_func
|
||||
generate_book_commentary = book_commentary_func
|
||||
generate_word_study_sidenotes = word_study_func
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Book Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/book/{book}", response_class=HTMLResponse)
|
||||
async def read_book(request: Request, book: str):
|
||||
"""Display a Bible book overview with chapter listing."""
|
||||
# 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
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.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}"}
|
||||
)
|
||||
|
||||
|
||||
@router.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)
|
||||
|
||||
|
||||
@router.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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Chapter Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/book/{book}/chapter/{chapter}", response_class=HTMLResponse)
|
||||
async def read_chapter(request: Request, book: str, chapter: int):
|
||||
"""Display a Bible chapter with 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}", 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 = {}
|
||||
shown_words = set() # Track which words have already been shown in this chapter
|
||||
for verse in verses:
|
||||
commentary = generate_commentary(book, chapter, verse)
|
||||
# Add word study sidenotes (avoiding repetition within chapter)
|
||||
word_studies = generate_word_study_sidenotes(verse.text, book, chapter, verse.verse, shown_words)
|
||||
commentary['word_studies'] = word_studies
|
||||
# Track which words were shown
|
||||
for study in word_studies:
|
||||
shown_words.add(study['word'].lower())
|
||||
# Add cross-references with proper URLs, grouped by description
|
||||
cross_refs = get_cross_references(book, chapter, verse.verse)
|
||||
|
||||
# Group cross-references by their description/note
|
||||
grouped_refs = defaultdict(list)
|
||||
for ref in cross_refs:
|
||||
description = ref['note'] if ref['note'] else 'Related'
|
||||
# Parse the reference to build URL
|
||||
if ' ' in ref['ref'] and ':' in ref['ref']:
|
||||
ref_book = ref['ref'].rsplit(' ', 1)[0]
|
||||
ref_chapter_verse = ref['ref'].rsplit(' ', 1)[1]
|
||||
ref_chapter = ref_chapter_verse.split(':')[0]
|
||||
ref_verse = ref_chapter_verse.split(':')[1]
|
||||
# Same chapter: use anchor link; different chapter/book: link to chapter view with anchor
|
||||
if ref_book == book and ref_chapter == str(chapter):
|
||||
url = f"#verse-{ref_verse}"
|
||||
else:
|
||||
url = f"/book/{ref_book}/chapter/{ref_chapter}#verse-{ref_verse}"
|
||||
else:
|
||||
url = '#'
|
||||
grouped_refs[description].append({
|
||||
'text': ref['ref'],
|
||||
'url': url
|
||||
})
|
||||
|
||||
# Convert to list of groups for template
|
||||
commentary['cross_reference_groups'] = [
|
||||
{'description': desc, 'refs': refs}
|
||||
for desc, refs in grouped_refs.items()
|
||||
]
|
||||
commentaries[verse.verse] = commentary
|
||||
|
||||
# Generate chapter overview
|
||||
chapter_overview = generate_chapter_overview(book, chapter, verses)
|
||||
|
||||
# Get related content for internal linking
|
||||
related_content = get_related_content(book, chapter)
|
||||
|
||||
# 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,
|
||||
"related_content": related_content
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.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."
|
||||
)
|
||||
|
||||
# Generate commentaries with cross-references and word studies for PDF
|
||||
commentaries = {}
|
||||
shown_words = set()
|
||||
for verse in verses:
|
||||
commentary = generate_commentary(book, chapter, verse)
|
||||
# Add word study sidenotes (avoiding repetition within chapter, more liberal for PDF)
|
||||
word_studies = generate_word_study_sidenotes(verse.text, book, chapter, verse.verse, shown_words, for_pdf=True)
|
||||
commentary['word_studies'] = word_studies
|
||||
for study in word_studies:
|
||||
shown_words.add(study['word'].lower())
|
||||
|
||||
# Add cross-references grouped by description
|
||||
cross_refs = get_cross_references(book, chapter, verse.verse)
|
||||
grouped_refs = defaultdict(list)
|
||||
for ref in cross_refs:
|
||||
description = ref['note'] if ref['note'] else 'Related'
|
||||
grouped_refs[description].append(ref['ref'])
|
||||
|
||||
commentary['cross_reference_groups'] = [
|
||||
{'description': desc, 'refs': refs}
|
||||
for desc, refs in grouped_refs.items()
|
||||
]
|
||||
commentaries[verse.verse] = commentary
|
||||
|
||||
# Get book metadata for richer PDF
|
||||
book_data = get_book_data(book)
|
||||
total_chapters = len(chapters)
|
||||
|
||||
# Collect all unique word studies shown for glossary
|
||||
glossary = []
|
||||
seen_words = set()
|
||||
for verse_num, commentary in commentaries.items():
|
||||
for study in commentary.get('word_studies', []):
|
||||
word_lower = study['word'].lower()
|
||||
if word_lower not in seen_words:
|
||||
seen_words.add(word_lower)
|
||||
glossary.append(study)
|
||||
glossary.sort(key=lambda x: x['word'])
|
||||
|
||||
html_content = templates.get_template("chapter_pdf.html").render(
|
||||
book=book,
|
||||
chapter=chapter,
|
||||
verses=verses,
|
||||
verse_count=len(verses),
|
||||
commentaries=commentaries,
|
||||
book_data=book_data,
|
||||
total_chapters=total_chapters,
|
||||
glossary=glossary,
|
||||
)
|
||||
|
||||
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}"}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Interlinear Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/book/{book}/chapter/{chapter}/interlinear/pdf")
|
||||
async def chapter_interlinear_pdf(book: str, chapter: int):
|
||||
"""Generate PDF export for interlinear chapter view."""
|
||||
# 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}/interlinear/pdf", status_code=301)
|
||||
|
||||
if not WEASYPRINT_AVAILABLE:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="PDF generation is not available. WeasyPrint system libraries are not installed."
|
||||
)
|
||||
|
||||
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.")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Chapter {chapter} of {book} was not found.")
|
||||
|
||||
# Get interlinear data for each verse
|
||||
verses_with_interlinear = []
|
||||
for verse in verses:
|
||||
interlinear_words = get_interlinear_data(book, chapter, verse.verse)
|
||||
verses_with_interlinear.append({
|
||||
'verse': verse,
|
||||
'interlinear_words': interlinear_words or []
|
||||
})
|
||||
|
||||
# Determine if OT or NT for language badge
|
||||
is_old_testament = book in OT_BOOKS
|
||||
|
||||
html_content = templates.get_template("chapter_interlinear_pdf.html").render(
|
||||
book=book,
|
||||
chapter=chapter,
|
||||
verses_with_interlinear=verses_with_interlinear,
|
||||
is_old_testament=is_old_testament
|
||||
)
|
||||
pdf_buffer = await render_html_to_pdf_async(html_content)
|
||||
|
||||
filename = f"{book.lower().replace(' ', '-')}-{chapter}-interlinear.pdf"
|
||||
return StreamingResponse(
|
||||
pdf_buffer,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/book/{book}/chapter/{chapter}/interlinear", response_class=HTMLResponse)
|
||||
async def read_chapter_interlinear(request: Request, book: str, chapter: int):
|
||||
"""Display a chapter with interlinear Hebrew/Greek for every verse"""
|
||||
# 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}/interlinear", 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:
|
||||
if not chapters:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"The book '{book}' was not found."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Chapter {chapter} of {book} was not found. This book has {len(chapters)} chapters."
|
||||
)
|
||||
|
||||
# Get interlinear data for each verse
|
||||
verses_with_interlinear = []
|
||||
for verse in verses:
|
||||
interlinear_words = get_interlinear_data(book, chapter, verse.verse)
|
||||
verses_with_interlinear.append({
|
||||
'verse': verse,
|
||||
'interlinear_words': interlinear_words or []
|
||||
})
|
||||
|
||||
# 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": "Interlinear", "url": None}
|
||||
]
|
||||
|
||||
# Determine if OT or NT for language badge
|
||||
is_old_testament = book in OT_BOOKS
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chapter_interlinear.html",
|
||||
{
|
||||
"book": book,
|
||||
"chapter": chapter,
|
||||
"verses_with_interlinear": verses_with_interlinear,
|
||||
"books": books,
|
||||
"chapters": chapters,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"current_book": book,
|
||||
"current_chapter": chapter,
|
||||
"is_old_testament": is_old_testament,
|
||||
"pdf_available": WEASYPRINT_AVAILABLE,
|
||||
"pdf_url": f"/book/{book}/chapter/{chapter}/interlinear/pdf" if WEASYPRINT_AVAILABLE else None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Verse Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/book/{book}/chapter/{chapter}/verse/{verse_num}/pdf")
|
||||
async def verse_pdf(book: str, chapter: int, verse_num: int):
|
||||
"""Generate PDF export for a single verse with 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}/pdf", status_code=301)
|
||||
|
||||
if not WEASYPRINT_AVAILABLE:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="PDF generation is not available. WeasyPrint system libraries are not installed."
|
||||
)
|
||||
|
||||
verses = bible.get_verses_by_book_chapter(book, chapter)
|
||||
if not verses:
|
||||
raise HTTPException(status_code=404, detail=f"Chapter {chapter} of {book} was not found.")
|
||||
|
||||
# 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}.")
|
||||
|
||||
# Generate commentary
|
||||
try:
|
||||
commentary = generate_commentary(book, chapter, verse)
|
||||
except Exception:
|
||||
commentary = None
|
||||
|
||||
# Get cross-references
|
||||
cross_refs = get_cross_references(book, chapter, verse_num)
|
||||
|
||||
# Get interlinear data
|
||||
interlinear_words = get_interlinear_data(book, chapter, verse_num)
|
||||
|
||||
# Determine if OT
|
||||
is_ot = book in OT_BOOKS
|
||||
|
||||
html_content = templates.get_template("verse_pdf.html").render(
|
||||
book=book,
|
||||
chapter=chapter,
|
||||
verse_num=verse_num,
|
||||
verse_text=verse.text,
|
||||
commentary=commentary,
|
||||
cross_references=cross_refs,
|
||||
interlinear_words=interlinear_words,
|
||||
is_old_testament=is_ot
|
||||
)
|
||||
pdf_buffer = await render_html_to_pdf_async(html_content)
|
||||
|
||||
filename = f"{book.lower().replace(' ', '-')}-{chapter}-{verse_num}.pdf"
|
||||
return StreamingResponse(
|
||||
pdf_buffer,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/book/{book}/chapter/{chapter}/verse/{verse_num}", response_class=HTMLResponse)
|
||||
async 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}
|
||||
]
|
||||
|
||||
# Determine if Old Testament for interlinear styling
|
||||
is_ot = book in OT_BOOKS
|
||||
|
||||
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,
|
||||
"is_old_testament": is_ot,
|
||||
"pdf_available": WEASYPRINT_AVAILABLE,
|
||||
"pdf_url": f"/book/{book}/chapter/{chapter}/verse/{verse_num}/pdf" if WEASYPRINT_AVAILABLE else None
|
||||
}
|
||||
)
|
||||
@@ -732,4 +732,68 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if related_content and (related_content.topics or related_content.people or related_content.resources or related_content.stories) %}
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 2rem; margin-top: 3rem;">
|
||||
<h2>Related Resources</h2>
|
||||
<p style="font-size: 0.95rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
|
||||
Explore related topics, people, and study resources to deepen your understanding of this chapter.
|
||||
</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
|
||||
{% if related_content.topics %}
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text-secondary);">Topics</h3>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for topic in related_content.topics %}
|
||||
<li style="margin-bottom: 0.5rem;">
|
||||
<a href="{{ topic.url }}" style="font-size: 0.95rem;">{{ topic.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if related_content.people %}
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text-secondary);">People</h3>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for person in related_content.people %}
|
||||
<li style="margin-bottom: 0.5rem;">
|
||||
<a href="{{ person.url }}" style="font-size: 0.95rem;">{{ person.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if related_content.resources %}
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text-secondary);">Study Resources</h3>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for resource in related_content.resources %}
|
||||
<li style="margin-bottom: 0.5rem;">
|
||||
<a href="{{ resource.url }}" style="font-size: 0.95rem;">{{ resource.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if related_content.stories %}
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text-secondary);">Bible Stories</h3>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for story in related_content.stories %}
|
||||
<li style="margin-bottom: 0.5rem;">
|
||||
<a href="{{ story.url }}" style="font-size: 0.95rem;">{{ story.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -984,28 +984,28 @@
|
||||
|
||||
{% if commentary %}
|
||||
{% if commentary.analysis %}
|
||||
<div>
|
||||
<div class="commentary-section">
|
||||
<h2>Analysis</h2>
|
||||
<p>{{ commentary.analysis|format_lists|safe }}</p>
|
||||
{{ commentary.analysis|split_paragraphs|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if commentary.historical %}
|
||||
<div>
|
||||
<div class="commentary-section">
|
||||
<h2>Historical Context</h2>
|
||||
<p>{{ commentary.historical|format_lists|safe }}</p>
|
||||
{{ commentary.historical|split_paragraphs|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if commentary.theological %}
|
||||
<div>
|
||||
<div class="commentary-section">
|
||||
<h2>Theological Significance</h2>
|
||||
<p>{{ commentary.theological|format_lists|safe }}</p>
|
||||
{{ commentary.theological|split_paragraphs|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if commentary.questions %}
|
||||
<div>
|
||||
<div class="commentary-section">
|
||||
<h2>Questions for Reflection</h2>
|
||||
<ul>
|
||||
{% for question in commentary.questions %}
|
||||
@@ -1020,15 +1020,24 @@
|
||||
<script>
|
||||
(function() {
|
||||
// Collect all readable elements: paragraphs, list items, cross-ref items
|
||||
var elements = Array.from(document.querySelectorAll(
|
||||
'section > p, ' +
|
||||
'.cross-references-section li, ' +
|
||||
'div > p, ' +
|
||||
'.interlinear-container'
|
||||
)).filter(function(el) {
|
||||
// Filter out empty or very short elements
|
||||
return el.textContent.trim().length > 10 || el.classList.contains('interlinear-container');
|
||||
});
|
||||
function getElements() {
|
||||
return Array.from(document.querySelectorAll(
|
||||
'section > p.verse-text, ' +
|
||||
'.interlinear-container, ' +
|
||||
'#crossrefs-content > p, ' +
|
||||
'.cross-references-section li, ' +
|
||||
'.commentary-section p, ' +
|
||||
'.commentary-section li'
|
||||
)).filter(function(el) {
|
||||
// Filter out empty or very short elements, and UI elements
|
||||
if (el.closest('.word-detail') || el.closest('.share-container') || el.closest('nav')) return false;
|
||||
// Skip elements inside collapsed sections
|
||||
var hiddenParent = el.closest('[hidden]');
|
||||
if (hiddenParent) return false;
|
||||
return el.textContent.trim().length > 10 || el.classList.contains('interlinear-container');
|
||||
});
|
||||
}
|
||||
var elements = getElements();
|
||||
|
||||
var selectedIndex = -1;
|
||||
var inWordMode = false;
|
||||
@@ -1064,6 +1073,9 @@
|
||||
|
||||
function selectElement(index) {
|
||||
clearSelection();
|
||||
// Refresh elements list in case sections were toggled
|
||||
elements = getElements();
|
||||
if (elements.length === 0) return;
|
||||
selectedIndex = Math.max(0, Math.min(index, elements.length - 1));
|
||||
elements[selectedIndex].style.outline = '2px solid #4a7c59';
|
||||
elements[selectedIndex].style.outlineOffset = '4px';
|
||||
@@ -1210,7 +1222,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% if related_content and (related_content.topics or related_content.people or related_content.resources) %}
|
||||
{% if related_content and (related_content.topics or related_content.people or related_content.resources or related_content.stories) %}
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 2rem; margin-top: 3rem;">
|
||||
<h2>Related Resources</h2>
|
||||
<p style="font-size: 0.95rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
|
||||
@@ -1256,6 +1268,19 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if related_content.stories %}
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text-secondary);">Bible Stories</h3>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for story in related_content.stories %}
|
||||
<li style="margin-bottom: 0.5rem;">
|
||||
<a href="{{ story.url }}" style="font-size: 0.95rem;">{{ story.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -10,6 +10,7 @@ from functools import lru_cache
|
||||
from ..kjv import bible, VerseReference
|
||||
from ..topics import get_all_topics
|
||||
from ..red_letter import get_christ_words
|
||||
from ..stories import get_all_stories_flat
|
||||
|
||||
# Paths to data files
|
||||
_DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
@@ -146,9 +147,37 @@ def get_related_content(book: str, chapter: int = None, verse: int = None) -> Di
|
||||
"study_guides": [],
|
||||
"topics": [],
|
||||
"people": [],
|
||||
"resources": []
|
||||
"resources": [],
|
||||
"stories": []
|
||||
}
|
||||
|
||||
# Find related Bible stories based on verse references
|
||||
all_stories = get_all_stories_flat()
|
||||
for story in all_stories:
|
||||
story_verses = story.get("verses", [])
|
||||
for verse_ref in story_verses:
|
||||
# Parse verse references like "Genesis 1:1-31" or "Genesis 2:1-3"
|
||||
if verse_ref.startswith(book + " "):
|
||||
# Extract chapter from reference
|
||||
ref_part = verse_ref[len(book) + 1:] # e.g., "1:1-31" or "2:1-3"
|
||||
if ":" in ref_part:
|
||||
ref_chapter = ref_part.split(":")[0]
|
||||
try:
|
||||
ref_chapter_num = int(ref_chapter)
|
||||
# If chapter matches (or no chapter specified), include this story
|
||||
if chapter is None or ref_chapter_num == chapter:
|
||||
story_entry = {
|
||||
"name": story.get("title", ""),
|
||||
"url": f"/stories/{story.get('slug', '')}",
|
||||
"description": story.get("description", "")[:100] + "..." if len(story.get("description", "")) > 100 else story.get("description", "")
|
||||
}
|
||||
# Avoid duplicates
|
||||
if story_entry not in related["stories"]:
|
||||
related["stories"].append(story_entry)
|
||||
break # Only add each story once per book/chapter match
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Map books to related people
|
||||
book_people_map = {
|
||||
"Genesis": [{"name": "Abraham", "url": "/family-tree/person/i60"}, {"name": "Jacob", "url": "/family-tree/person/i58"}],
|
||||
@@ -188,21 +217,34 @@ def get_related_content(book: str, chapter: int = None, verse: int = None) -> Di
|
||||
if book in ["Matthew", "Mark", "Luke", "John"]:
|
||||
related["resources"].append({"name": "Parables of Jesus", "url": "/parables"})
|
||||
|
||||
# Add topic links based on common themes
|
||||
topic_keywords = {
|
||||
"Salvation": ["John", "Romans", "Ephesians", "Titus"],
|
||||
"Prayer": ["Matthew", "Luke", "1 Thessalonians", "James"],
|
||||
"Love": ["John", "1 Corinthians", "1 John"],
|
||||
"Faith": ["Hebrews", "James", "Romans"],
|
||||
"Hope": ["Romans", "1 Peter", "Hebrews"],
|
||||
"Peace": ["Philippians", "John", "Romans"],
|
||||
"Wisdom": ["Proverbs", "Ecclesiastes", "James"],
|
||||
}
|
||||
|
||||
# Add topic links based on verse references in topic data
|
||||
topics_data = get_all_topics()
|
||||
for topic_name in topics_data.keys():
|
||||
if topic_name in topic_keywords and book in topic_keywords[topic_name]:
|
||||
related["topics"].append({"name": topic_name, "url": f"/topics/{topic_name}"})
|
||||
for topic_name, topic_data in topics_data.items():
|
||||
topic_matched = False
|
||||
# Check subtopics for verse references
|
||||
subtopics = topic_data.get("subtopics", {})
|
||||
for subtopic_name, subtopic_data in subtopics.items():
|
||||
if topic_matched:
|
||||
break
|
||||
verses = subtopic_data.get("verses", [])
|
||||
for verse_entry in verses:
|
||||
ref = verse_entry.get("ref", "")
|
||||
# Check if this reference matches our book/chapter
|
||||
if ref.startswith(book + " "):
|
||||
ref_part = ref[len(book) + 1:]
|
||||
if ":" in ref_part:
|
||||
ref_chapter = ref_part.split(":")[0]
|
||||
try:
|
||||
ref_chapter_num = int(ref_chapter)
|
||||
if chapter is None or ref_chapter_num == chapter:
|
||||
related["topics"].append({
|
||||
"name": topic_name,
|
||||
"url": f"/topics/{topic_name}"
|
||||
})
|
||||
topic_matched = True
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return related
|
||||
|
||||
|
||||
Reference in New Issue
Block a user