Add 6 new reading plans with full Scripture text in web/PDF

New reading plans:
- Books of Moses in 40 Days (pentateuch_40)
- Major Prophets in 60 Days (prophets_60)
- Minor Prophets in 14 Days (minor_prophets_14)
- Wisdom Literature in 30 Days (wisdom_30)
- Historical Books in 45 Days (historical_45)
- General Epistles in 14 Days (general_epistles_14)

Features:
- Reading plan pages now show full Scripture text for plans 90 days or less
- Day navigation bar for quick jumping between days
- PDF exports include full Bible text for shorter plans
- Refactored Jinja filters into separate jinja_filters.py module
- Added parse_reading_reference() and get_reading_text() helpers

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 16:00:24 -05:00
parent 7770c37dc5
commit 3793991032
11 changed files with 926 additions and 353 deletions
@@ -0,0 +1,18 @@
{
"general_epistles_14": [
{"day": 1, "readings": ["Hebrews 1-4"], "theme": "Christ Superior to Angels and Moses"},
{"day": 2, "readings": ["Hebrews 5-7"], "theme": "Melchizedek Priesthood"},
{"day": 3, "readings": ["Hebrews 8-10"], "theme": "New Covenant, Better Sacrifice"},
{"day": 4, "readings": ["Hebrews 11-13"], "theme": "Hall of Faith, Endurance"},
{"day": 5, "readings": ["James 1-3"], "theme": "Faith and Works, Taming the Tongue"},
{"day": 6, "readings": ["James 4-5"], "theme": "Humility, Patience, Prayer"},
{"day": 7, "readings": ["1 Peter 1-3"], "theme": "Living Hope, Submission"},
{"day": 8, "readings": ["1 Peter 4-5"], "theme": "Suffering for Christ, Shepherding"},
{"day": 9, "readings": ["2 Peter 1-3"], "theme": "Knowledge, False Teachers, Day of the Lord"},
{"day": 10, "readings": ["1 John 1-3"], "theme": "Fellowship, Walking in Light, Love"},
{"day": 11, "readings": ["1 John 4-5"], "theme": "Testing Spirits, God is Love, Assurance"},
{"day": 12, "readings": ["2 John", "3 John"], "theme": "Truth and Love, Hospitality"},
{"day": 13, "readings": ["Jude"], "theme": "Contending for the Faith"},
{"day": 14, "readings": ["Revelation 1-3"], "theme": "Vision of Christ, Letters to Churches"}
]
}
@@ -0,0 +1,49 @@
{
"historical_45": [
{"day": 1, "readings": ["Joshua 1-5"], "theme": "Entering the Promised Land"},
{"day": 2, "readings": ["Joshua 6-10"], "theme": "Jericho Falls, Conquest Begins"},
{"day": 3, "readings": ["Joshua 11-15"], "theme": "Northern Conquest, Land Division"},
{"day": 4, "readings": ["Joshua 16-21"], "theme": "Tribal Allotments, Cities of Refuge"},
{"day": 5, "readings": ["Joshua 22-24"], "theme": "Joshua's Farewell, Covenant Renewed"},
{"day": 6, "readings": ["Judges 1-5"], "theme": "Incomplete Conquest, Deborah"},
{"day": 7, "readings": ["Judges 6-9"], "theme": "Gideon's Victory"},
{"day": 8, "readings": ["Judges 10-14"], "theme": "Jephthah, Samson's Birth"},
{"day": 9, "readings": ["Judges 15-18"], "theme": "Samson's Exploits, Micah's Idols"},
{"day": 10, "readings": ["Judges 19-21"], "theme": "Benjamite War"},
{"day": 11, "readings": ["Ruth 1-4"], "theme": "Loyalty, Redemption, David's Ancestry"},
{"day": 12, "readings": ["1 Samuel 1-5"], "theme": "Samuel's Birth, Ark Captured"},
{"day": 13, "readings": ["1 Samuel 6-10"], "theme": "Ark Returned, Saul Anointed King"},
{"day": 14, "readings": ["1 Samuel 11-15"], "theme": "Saul's Reign, Rejection"},
{"day": 15, "readings": ["1 Samuel 16-20"], "theme": "David Anointed, Goliath, Jonathan"},
{"day": 16, "readings": ["1 Samuel 21-25"], "theme": "David Flees, Nabal and Abigail"},
{"day": 17, "readings": ["1 Samuel 26-31"], "theme": "David Spares Saul, Saul's Death"},
{"day": 18, "readings": ["2 Samuel 1-5"], "theme": "David Becomes King"},
{"day": 19, "readings": ["2 Samuel 6-10"], "theme": "Ark to Jerusalem, Davidic Covenant"},
{"day": 20, "readings": ["2 Samuel 11-14"], "theme": "David and Bathsheba, Absalom"},
{"day": 21, "readings": ["2 Samuel 15-19"], "theme": "Absalom's Rebellion"},
{"day": 22, "readings": ["2 Samuel 20-24"], "theme": "Sheba's Revolt, David's Last Words"},
{"day": 23, "readings": ["1 Kings 1-4"], "theme": "Solomon Becomes King, Wisdom"},
{"day": 24, "readings": ["1 Kings 5-8"], "theme": "Temple Built and Dedicated"},
{"day": 25, "readings": ["1 Kings 9-11"], "theme": "Solomon's Glory and Fall"},
{"day": 26, "readings": ["1 Kings 12-16"], "theme": "Kingdom Divided, Evil Kings"},
{"day": 27, "readings": ["1 Kings 17-19"], "theme": "Elijah, Drought, Mount Carmel"},
{"day": 28, "readings": ["1 Kings 20-22"], "theme": "Ahab's Wars, Naboth's Vineyard"},
{"day": 29, "readings": ["2 Kings 1-5"], "theme": "Elijah Taken Up, Elisha's Miracles"},
{"day": 30, "readings": ["2 Kings 6-10"], "theme": "Siege, Jehu's Revolution"},
{"day": 31, "readings": ["2 Kings 11-15"], "theme": "Joash, Decline of Israel"},
{"day": 32, "readings": ["2 Kings 16-19"], "theme": "Ahaz, Hezekiah, Assyrian Threat"},
{"day": 33, "readings": ["2 Kings 20-23"], "theme": "Hezekiah's Illness, Josiah's Reforms"},
{"day": 34, "readings": ["2 Kings 24-25"], "theme": "Jerusalem Falls, Exile"},
{"day": 35, "readings": ["1 Chronicles 1-9"], "theme": "Genealogies from Adam to Saul"},
{"day": 36, "readings": ["1 Chronicles 10-16"], "theme": "David's Reign, Ark to Jerusalem"},
{"day": 37, "readings": ["1 Chronicles 17-22"], "theme": "Davidic Covenant, Temple Preparations"},
{"day": 38, "readings": ["1 Chronicles 23-29"], "theme": "Levites, Solomon Anointed"},
{"day": 39, "readings": ["2 Chronicles 1-7"], "theme": "Solomon's Temple"},
{"day": 40, "readings": ["2 Chronicles 8-16"], "theme": "Solomon to Asa"},
{"day": 41, "readings": ["2 Chronicles 17-24"], "theme": "Jehoshaphat to Joash"},
{"day": 42, "readings": ["2 Chronicles 25-32"], "theme": "Amaziah to Hezekiah"},
{"day": 43, "readings": ["2 Chronicles 33-36"], "theme": "Manasseh to Exile"},
{"day": 44, "readings": ["Ezra 1-6", "Nehemiah 1-2"], "theme": "Return from Exile, Temple Rebuilt"},
{"day": 45, "readings": ["Nehemiah 3-13", "Esther 1-10"], "theme": "Walls Rebuilt, Esther Saves Her People"}
]
}
@@ -0,0 +1,18 @@
{
"minor_prophets_14": [
{"day": 1, "readings": ["Hosea 1-7"], "theme": "God's Faithful Love for Unfaithful Israel"},
{"day": 2, "readings": ["Hosea 8-14"], "theme": "Judgment and Restoration"},
{"day": 3, "readings": ["Joel 1-3"], "theme": "Day of the Lord and Spirit Poured Out"},
{"day": 4, "readings": ["Amos 1-5"], "theme": "Judgment on Nations and Israel"},
{"day": 5, "readings": ["Amos 6-9"], "theme": "Woe and Future Restoration"},
{"day": 6, "readings": ["Obadiah", "Jonah 1-4"], "theme": "Edom's Doom, Jonah and God's Mercy"},
{"day": 7, "readings": ["Micah 1-7"], "theme": "Judgment, Bethlehem Prophecy, God's Requirements"},
{"day": 8, "readings": ["Nahum 1-3"], "theme": "Nineveh's Destruction"},
{"day": 9, "readings": ["Habakkuk 1-3"], "theme": "The Just Shall Live by Faith"},
{"day": 10, "readings": ["Zephaniah 1-3"], "theme": "Day of the Lord, Remnant Restored"},
{"day": 11, "readings": ["Haggai 1-2"], "theme": "Rebuild the Temple"},
{"day": 12, "readings": ["Zechariah 1-7"], "theme": "Visions of Restoration"},
{"day": 13, "readings": ["Zechariah 8-14"], "theme": "Messianic King, Pierced One"},
{"day": 14, "readings": ["Malachi 1-4"], "theme": "Messenger of the Covenant, Elijah Returns"}
]
}
@@ -0,0 +1,44 @@
{
"pentateuch_40": [
{"day": 1, "readings": ["Genesis 1-4"], "theme": "Creation, Fall, Cain and Abel"},
{"day": 2, "readings": ["Genesis 5-9"], "theme": "Genealogy, Noah, the Flood, Covenant"},
{"day": 3, "readings": ["Genesis 10-14"], "theme": "Nations, Babel, Call of Abram, Lot"},
{"day": 4, "readings": ["Genesis 15-19"], "theme": "Covenant, Ishmael, Sodom and Gomorrah"},
{"day": 5, "readings": ["Genesis 20-24"], "theme": "Isaac Born, Sacrifice, Rebekah"},
{"day": 6, "readings": ["Genesis 25-28"], "theme": "Abraham Dies, Jacob and Esau, Bethel"},
{"day": 7, "readings": ["Genesis 29-32"], "theme": "Jacob, Laban, Wrestles with God"},
{"day": 8, "readings": ["Genesis 33-36"], "theme": "Reconciliation, Dinah, Israel"},
{"day": 9, "readings": ["Genesis 37-41"], "theme": "Joseph's Dreams, Judah and Tamar, Egypt"},
{"day": 10, "readings": ["Genesis 42-46"], "theme": "Brothers in Egypt, Reconciliation, Jacob Goes"},
{"day": 11, "readings": ["Genesis 47-50"], "theme": "Jacob Blesses, Deaths"},
{"day": 12, "readings": ["Exodus 1-4"], "theme": "Bondage, Birth of Moses, Burning Bush"},
{"day": 13, "readings": ["Exodus 5-8"], "theme": "Pharaoh's Heart, First Plagues"},
{"day": 14, "readings": ["Exodus 9-12"], "theme": "More Plagues, Passover Instituted"},
{"day": 15, "readings": ["Exodus 13-16"], "theme": "Red Sea Crossing, Manna"},
{"day": 16, "readings": ["Exodus 17-20"], "theme": "Water from Rock, Jethro, Ten Commandments"},
{"day": 17, "readings": ["Exodus 21-24"], "theme": "Laws, Covenant Confirmed"},
{"day": 18, "readings": ["Exodus 25-28"], "theme": "Tabernacle Instructions, Priestly Garments"},
{"day": 19, "readings": ["Exodus 29-32"], "theme": "Priestly Consecration, Golden Calf"},
{"day": 20, "readings": ["Exodus 33-36"], "theme": "God's Presence, Tablets Renewed"},
{"day": 21, "readings": ["Exodus 37-40"], "theme": "Tabernacle Completed, Glory Fills"},
{"day": 22, "readings": ["Leviticus 1-5"], "theme": "Burnt, Grain, Peace, Sin, Guilt Offerings"},
{"day": 23, "readings": ["Leviticus 6-10"], "theme": "Offering Laws, Priests Ordained, Nadab and Abihu"},
{"day": 24, "readings": ["Leviticus 11-15"], "theme": "Clean and Unclean, Purification"},
{"day": 25, "readings": ["Leviticus 16-20"], "theme": "Day of Atonement, Holiness Code"},
{"day": 26, "readings": ["Leviticus 21-24"], "theme": "Priests, Feasts, Blasphemer"},
{"day": 27, "readings": ["Leviticus 25-27"], "theme": "Sabbath Year, Jubilee, Vows"},
{"day": 28, "readings": ["Numbers 1-4"], "theme": "Census, Camp Arrangement, Levites"},
{"day": 29, "readings": ["Numbers 5-8"], "theme": "Purity Laws, Nazarites, Levite Service"},
{"day": 30, "readings": ["Numbers 9-12"], "theme": "Passover, Cloud and Fire, Complaints"},
{"day": 31, "readings": ["Numbers 13-16"], "theme": "Spies, Rebellion, Korah"},
{"day": 32, "readings": ["Numbers 17-21"], "theme": "Aaron's Rod, Red Heifer, Bronze Serpent"},
{"day": 33, "readings": ["Numbers 22-26"], "theme": "Balaam, Baal of Peor, Second Census"},
{"day": 34, "readings": ["Numbers 27-32"], "theme": "Zelophehad's Daughters, Joshua, Vows, Midianites"},
{"day": 35, "readings": ["Numbers 33-36"], "theme": "Journey Stages, Land Boundaries, Levite Cities"},
{"day": 36, "readings": ["Deuteronomy 1-4"], "theme": "Moses Reviews History, Obedience"},
{"day": 37, "readings": ["Deuteronomy 5-11"], "theme": "Ten Commandments, Shema, Warnings"},
{"day": 38, "readings": ["Deuteronomy 12-20"], "theme": "Worship, Clean Foods, Laws, Cities of Refuge"},
{"day": 39, "readings": ["Deuteronomy 21-28"], "theme": "Various Laws, Blessings and Curses"},
{"day": 40, "readings": ["Deuteronomy 29-34"], "theme": "Covenant Renewed, Song of Moses, Moses Dies"}
]
}
@@ -0,0 +1,64 @@
{
"prophets_60": [
{"day": 1, "readings": ["Isaiah 1-3"], "theme": "Judah's Rebellion and Call to Return"},
{"day": 2, "readings": ["Isaiah 4-6"], "theme": "Isaiah's Vision and Call"},
{"day": 3, "readings": ["Isaiah 7-9"], "theme": "Immanuel and Prince of Peace"},
{"day": 4, "readings": ["Isaiah 10-12"], "theme": "Remnant and Branch of Jesse"},
{"day": 5, "readings": ["Isaiah 13-16"], "theme": "Oracles Against Babylon and Moab"},
{"day": 6, "readings": ["Isaiah 17-20"], "theme": "Damascus, Cush, and Egypt"},
{"day": 7, "readings": ["Isaiah 21-23"], "theme": "Desert, Dumah, and Tyre"},
{"day": 8, "readings": ["Isaiah 24-27"], "theme": "God's Universal Judgment and Victory"},
{"day": 9, "readings": ["Isaiah 28-30"], "theme": "Woes to Ephraim and Jerusalem"},
{"day": 10, "readings": ["Isaiah 31-33"], "theme": "Trust in God, Not Egypt"},
{"day": 11, "readings": ["Isaiah 34-36"], "theme": "Judgment and Hezekiah's Crisis"},
{"day": 12, "readings": ["Isaiah 37-39"], "theme": "Deliverance and Babylonian Embassy"},
{"day": 13, "readings": ["Isaiah 40-42"], "theme": "Comfort and The Servant"},
{"day": 14, "readings": ["Isaiah 43-45"], "theme": "Redemption and Cyrus"},
{"day": 15, "readings": ["Isaiah 46-48"], "theme": "Babylon's Gods Fall"},
{"day": 16, "readings": ["Isaiah 49-51"], "theme": "Servant's Mission to Nations"},
{"day": 17, "readings": ["Isaiah 52-54"], "theme": "The Suffering Servant"},
{"day": 18, "readings": ["Isaiah 55-57"], "theme": "Invitation to Grace"},
{"day": 19, "readings": ["Isaiah 58-60"], "theme": "True Fasting and Zion's Glory"},
{"day": 20, "readings": ["Isaiah 61-63"], "theme": "Year of Favor and Divine Warrior"},
{"day": 21, "readings": ["Isaiah 64-66"], "theme": "Prayer and New Creation"},
{"day": 22, "readings": ["Jeremiah 1-3"], "theme": "Jeremiah's Call and Israel's Adultery"},
{"day": 23, "readings": ["Jeremiah 4-6"], "theme": "Invasion from the North"},
{"day": 24, "readings": ["Jeremiah 7-9"], "theme": "Temple Sermon and Lament"},
{"day": 25, "readings": ["Jeremiah 10-12"], "theme": "Idolatry and Jeremiah's Complaint"},
{"day": 26, "readings": ["Jeremiah 13-15"], "theme": "Signs of Judgment"},
{"day": 27, "readings": ["Jeremiah 16-18"], "theme": "No Marriage, Potter's House"},
{"day": 28, "readings": ["Jeremiah 19-21"], "theme": "Broken Jar, Persecution"},
{"day": 29, "readings": ["Jeremiah 22-24"], "theme": "Wicked Kings and Good Figs"},
{"day": 30, "readings": ["Jeremiah 25-26"], "theme": "Seventy Years and Temple Trial"},
{"day": 31, "readings": ["Jeremiah 27-29"], "theme": "Yokes and Letter to Exiles"},
{"day": 32, "readings": ["Jeremiah 30-31"], "theme": "Book of Consolation"},
{"day": 33, "readings": ["Jeremiah 32-33"], "theme": "Field at Anathoth, New Covenant"},
{"day": 34, "readings": ["Jeremiah 34-36"], "theme": "Broken Covenant, Scroll Burned"},
{"day": 35, "readings": ["Jeremiah 37-39"], "theme": "Jeremiah Imprisoned, Jerusalem Falls"},
{"day": 36, "readings": ["Jeremiah 40-42"], "theme": "Gedaliah and Flight to Egypt"},
{"day": 37, "readings": ["Jeremiah 43-45"], "theme": "In Egypt, Baruch's Promise"},
{"day": 38, "readings": ["Jeremiah 46-48"], "theme": "Egypt, Philistia, Moab"},
{"day": 39, "readings": ["Jeremiah 49-50"], "theme": "Ammon, Edom, Babylon"},
{"day": 40, "readings": ["Jeremiah 51-52"], "theme": "Babylon's Doom and Jerusalem's Fall"},
{"day": 41, "readings": ["Ezekiel 1-3"], "theme": "Ezekiel's Call and Commission"},
{"day": 42, "readings": ["Ezekiel 4-7"], "theme": "Signs of Siege and Judgment"},
{"day": 43, "readings": ["Ezekiel 8-11"], "theme": "Temple Abominations, Glory Departs"},
{"day": 44, "readings": ["Ezekiel 12-14"], "theme": "Signs and False Prophets"},
{"day": 45, "readings": ["Ezekiel 15-17"], "theme": "Useless Vine, Unfaithful Wife, Eagles"},
{"day": 46, "readings": ["Ezekiel 18-20"], "theme": "Individual Responsibility, Israel's History"},
{"day": 47, "readings": ["Ezekiel 21-23"], "theme": "Sword of the Lord, Two Sisters"},
{"day": 48, "readings": ["Ezekiel 24-26"], "theme": "Cooking Pot, Wife's Death, Tyre"},
{"day": 49, "readings": ["Ezekiel 27-29"], "theme": "Tyre's Wealth, Egypt's Fall"},
{"day": 50, "readings": ["Ezekiel 30-32"], "theme": "Egypt's Judgment"},
{"day": 51, "readings": ["Ezekiel 33-35"], "theme": "Watchman, Shepherds, Edom"},
{"day": 52, "readings": ["Ezekiel 36-37"], "theme": "New Heart, Valley of Dry Bones"},
{"day": 53, "readings": ["Ezekiel 38-39"], "theme": "Gog and Magog"},
{"day": 54, "readings": ["Ezekiel 40-42"], "theme": "New Temple Vision"},
{"day": 55, "readings": ["Ezekiel 43-45"], "theme": "Glory Returns, Prince's Duties"},
{"day": 56, "readings": ["Ezekiel 46-48"], "theme": "Worship, River, Land Division"},
{"day": 57, "readings": ["Daniel 1-3"], "theme": "Babylon, Dream, Fiery Furnace"},
{"day": 58, "readings": ["Daniel 4-6"], "theme": "Nebuchadnezzar's Pride, Lion's Den"},
{"day": 59, "readings": ["Daniel 7-9"], "theme": "Four Beasts, Seventy Weeks"},
{"day": 60, "readings": ["Daniel 10-12"], "theme": "Kings of North and South, End Times"}
]
}
@@ -0,0 +1,34 @@
{
"wisdom_30": [
{"day": 1, "readings": ["Job 1-3"], "theme": "Job's Suffering Begins"},
{"day": 2, "readings": ["Job 4-7"], "theme": "Eliphaz Speaks, Job Responds"},
{"day": 3, "readings": ["Job 8-11"], "theme": "Bildad and Zophar Speak"},
{"day": 4, "readings": ["Job 12-15"], "theme": "Job's Wisdom, Eliphaz Again"},
{"day": 5, "readings": ["Job 16-19"], "theme": "Job's Redeemer Lives"},
{"day": 6, "readings": ["Job 20-24"], "theme": "Wicked and Righteous"},
{"day": 7, "readings": ["Job 25-31"], "theme": "Job's Final Defense"},
{"day": 8, "readings": ["Job 32-37"], "theme": "Elihu's Speeches"},
{"day": 9, "readings": ["Job 38-42"], "theme": "God Speaks, Job Restored"},
{"day": 10, "readings": ["Psalms 1-18"], "theme": "Blessed Man, Messianic Psalms"},
{"day": 11, "readings": ["Psalms 19-33"], "theme": "Heavens Declare, Good Shepherd"},
{"day": 12, "readings": ["Psalms 34-45"], "theme": "Taste and See, Royal Wedding"},
{"day": 13, "readings": ["Psalms 46-59"], "theme": "God Our Refuge, Mercy Psalms"},
{"day": 14, "readings": ["Psalms 60-72"], "theme": "Rock Higher Than I, Solomon's Prayer"},
{"day": 15, "readings": ["Psalms 73-85"], "theme": "Asaph's Struggles, Revival"},
{"day": 16, "readings": ["Psalms 86-98"], "theme": "Teach Me Thy Way, New Song"},
{"day": 17, "readings": ["Psalms 99-110"], "theme": "Enthronement, Melchizedek"},
{"day": 18, "readings": ["Psalms 111-118"], "theme": "Hallel, Stone Rejected"},
{"day": 19, "readings": ["Psalms 119"], "theme": "The Word of God"},
{"day": 20, "readings": ["Psalms 120-134"], "theme": "Songs of Ascent"},
{"day": 21, "readings": ["Psalms 135-150"], "theme": "Praise Collection, Hallelujah"},
{"day": 22, "readings": ["Proverbs 1-5"], "theme": "Wisdom's Call, Avoid Folly"},
{"day": 23, "readings": ["Proverbs 6-10"], "theme": "Warnings, Contrast of Wise and Foolish"},
{"day": 24, "readings": ["Proverbs 11-15"], "theme": "Righteous vs. Wicked"},
{"day": 25, "readings": ["Proverbs 16-20"], "theme": "The Lord Weighs Hearts"},
{"day": 26, "readings": ["Proverbs 21-25"], "theme": "Solomon's Proverbs Continued"},
{"day": 27, "readings": ["Proverbs 26-31"], "theme": "Fools, Agur, The Virtuous Woman"},
{"day": 28, "readings": ["Ecclesiastes 1-6"], "theme": "Vanity of Vanities, Time for Everything"},
{"day": 29, "readings": ["Ecclesiastes 7-12"], "theme": "Wisdom's Value, Remember Your Creator"},
{"day": 30, "readings": ["Song of Solomon 1-8"], "theme": "The Beloved and the Lover"}
]
}
+267
View File
@@ -0,0 +1,267 @@
"""
Custom Jinja2 template filters for KJV Study.
All filters are registered in register_filters() which should be called with
the Jinja2 environment after templates are initialized.
Available Filters:
slugify - Create URL-safe slugs from text
md - Convert Markdown to HTML (full block elements)
mdi - Convert inline Markdown (no paragraph wrapping)
link_names - Link person names to family tree pages
link_verses - Link verse references like "John 3:16" to verse pages
inject_word_markers - Add sidenote markers for word studies
red_letter - Wrap words of Christ in red letter spans
format_lists - Convert (1), (2) patterns to HTML ordered lists
split_paragraphs - Split text on double newlines into separate <p> tags
number_format - Format numbers with commas (31102 -> 31,102)
linkify_strongs - Convert Strong's refs (G1234, H5678) to links
"""
import re
import mistune
from .utils.helpers import create_slug
# Initialize mistune for markdown rendering
_markdown = mistune.create_markdown(escape=False, hard_wrap=False)
_inline_markdown = mistune.create_markdown(
renderer=mistune.HTMLRenderer(escape=False),
plugins=['strikethrough']
)
def markdown_to_html(text):
"""Convert markdown to HTML (bold, italic, paragraphs, etc.)."""
if not text:
return text
return _markdown(text).strip()
def markdown_inline(text):
"""Convert inline markdown to HTML (bold, italic, etc. - no paragraph wrapping)."""
if not text:
return text
html = _inline_markdown(text).strip()
if html.startswith('<p>') and html.endswith('</p>'):
html = html[3:-4]
return html
def link_person_names_in_text(text):
"""
Automatically link verse references and person names in text.
Links verse references like "Genesis 1:1" and person names like "Abraham"
to their respective pages.
"""
if not text:
return text
# First, link verse references
verse_pattern = r'\b((?:1|2|3)\s+)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(\d+):(\d+)(?:-(\d+))?\b'
def verse_replace_callback(match):
number_prefix = match.group(1) or ''
book_name = match.group(2)
chapter = match.group(3)
verse_start = match.group(4)
matched_text = match.group(0)
full_book = (number_prefix + book_name).strip()
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 (lazy import to avoid circular dependency)
from .server import get_person_name_mapping
name_to_id = get_person_name_mapping()
if not name_to_id:
return text
sorted_names = sorted(name_to_id.keys(), key=len, reverse=True)
for name_lower in sorted_names:
person_id = name_to_id[name_lower]
name_pattern = re.escape(name_lower)
def replace_callback(match, text=text, person_id=person_id):
matched_text = match.group(0)
start_pos = match.start()
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
return f'<a href="/family-tree/person/{person_id}">{matched_text}</a>'
pattern = r'\b' + name_pattern + r'\b'
text = re.sub(pattern, replace_callback, text, flags=re.IGNORECASE)
return 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 = 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 ''
book_name = match.group(2)
chapter = match.group(3)
verse_start = match.group(4)
verse_end = match.group(5)
full_book = (number_prefix + book_name).strip()
full_reference = match.group(0)
start_pos = match.start()
text_before = text[:start_pos]
last_lt = text_before.rfind('<')
last_gt = text_before.rfind('>')
if last_lt > last_gt:
return full_reference
if last_lt != -1:
tag_content = text[last_lt:start_pos]
if 'href=' in tag_content or 'src=' in tag_content:
return full_reference
if verse_end:
url = f'/book/{full_book}/chapter/{chapter}#verse-{verse_start}-{verse_end}'
else:
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)
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
for idx, study in enumerate(word_studies, 1):
word = study['word']
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>'
pattern = re.compile(r'\b(' + re.escape(word) + r')(?!\'[sS])\b', re.IGNORECASE)
text = pattern.sub(r'\1' + marker, text, count=1)
return text
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)
def format_numbered_lists(text):
"""Convert (1), (2), etc. patterns into HTML ordered lists"""
def _convert(pattern: str, payload: str) -> str:
markers = list(re.finditer(pattern, payload))
if len(markers) < 2:
return payload
numbers = [int(m.group(1)) for m in markers]
if numbers[0] != 1:
return payload
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 payload
markers = markers[:seq_length]
list_items = []
for i, marker in enumerate(markers):
start = marker.end()
if i + 1 < len(markers):
end = markers[i + 1].start()
else:
remaining = payload[start:]
end_match = re.search(r'[.;]\s+(?=[A-Z])|$', remaining)
end = start + end_match.start() + 1 if end_match else len(payload)
item_text = payload[start:end].strip().rstrip(';,')
if item_text.endswith(' and'):
item_text = item_text[:-4]
list_items.append(f'<li>{item_text}</li>')
html_list = '<ol>' + ''.join(list_items) + '</ol>'
list_start = markers[0].start()
last_marker = markers[-1]
last_item_start = last_marker.end()
remaining_after_last = payload[last_item_start:]
end_match = re.search(r'[.;]\s+(?=[A-Z])|$', remaining_after_last)
list_end = last_item_start + end_match.start() + 1 if end_match else len(payload)
after_list = payload[list_end:].strip()
if after_list:
return payload[:list_start] + html_list + '</p><p>' + after_list
return payload[:list_start] + html_list
primary_pattern = r'\((\d+)\)\s*'
converted = _convert(primary_pattern, text)
if converted != text:
return converted
fallback_pattern = r'(?<=\s)(\d+)\)\s*'
return _convert(fallback_pattern, text)
def split_paragraphs(text):
"""Convert double newlines or <br><br> into separate <p> tags for better navigation"""
if not text:
return text
parts = re.split(r'\n\n|<br\s*/?>\s*<br\s*/?>', text)
paragraphs = [f'<p>{p.strip()}</p>' for p in parts if p.strip()]
return '\n'.join(paragraphs)
def number_format(value):
"""Format a number with commas (e.g., 31102 -> 31,102)"""
return f"{value:,}"
def linkify_strongs(text):
"""Convert Strong's references like G1234 or H5678 to links."""
if not text:
return text
pattern = r'\b([GH])(\d+)\b'
def replace(match):
prefix = match.group(1)
num = match.group(2)
return f'<a href="/strongs/{prefix}{num}" class="strongs-ref">{prefix}{num}</a>'
return re.sub(pattern, replace, text)
def register_filters(env):
"""Register all custom filters with a Jinja2 environment."""
env.filters['slugify'] = create_slug
env.filters['md'] = markdown_to_html
env.filters['mdi'] = markdown_inline
env.filters['link_names'] = link_person_names_in_text
env.filters['link_verses'] = link_verse_references_in_text
env.filters['inject_word_markers'] = inject_word_markers
env.filters['red_letter'] = red_letter
env.filters['format_lists'] = format_numbered_lists
env.filters['split_paragraphs'] = split_paragraphs
env.filters['number_format'] = number_format
env.filters['linkify_strongs'] = linkify_strongs
+63
View File
@@ -37,6 +37,12 @@ NT_90_DAYS = _data.get("nt_90_days", {})
PSALMS_PROVERBS = _data.get("psalms_proverbs", {})
GOSPELS_ACTS_30 = _data.get("gospels_acts_30", {})
PAUL_EPISTLES_30 = _data.get("paul_epistles_30", {})
PENTATEUCH_40 = _data.get("pentateuch_40", {})
PROPHETS_60 = _data.get("prophets_60", {})
MINOR_PROPHETS_14 = _data.get("minor_prophets_14", {})
WISDOM_30 = _data.get("wisdom_30", {})
HISTORICAL_45 = _data.get("historical_45", {})
GENERAL_EPISTLES_14 = _data.get("general_epistles_14", {})
# Reading plans database
READING_PLANS = {
@@ -125,6 +131,7 @@ READING_PLANS = {
"description": "Study the Law and foundational narratives",
"duration_days": 40,
"overview": "Read Genesis through Deuteronomy in 40 days, exploring creation, the patriarchs, the Exodus, and the giving of the Law.",
"days": PENTATEUCH_40,
"sample_days": [
{"day": 1, "readings": ["Genesis 1-3"], "theme": "Creation and Fall"},
{"day": 10, "readings": ["Genesis 37-40"], "theme": "Joseph in Egypt"},
@@ -138,6 +145,7 @@ READING_PLANS = {
"description": "Read Isaiah, Jeremiah, Ezekiel, and Daniel",
"duration_days": 60,
"overview": "Study the major prophets, their messages of judgment and hope, and their Messianic prophecies fulfilled in Christ.",
"days": PROPHETS_60,
"sample_days": [
{"day": 1, "readings": ["Isaiah 1-3"], "theme": "Isaiah's call"},
{"day": 15, "readings": ["Isaiah 40-42"], "theme": "Comfort and the Servant"},
@@ -161,6 +169,61 @@ READING_PLANS = {
{"day": 25, "readings": ["1 Timothy 3-5"], "theme": "Church order"},
{"day": 30, "readings": ["Philemon"], "theme": "Forgiveness and brotherhood"},
]
},
"minor-prophets": {
"name": "Minor Prophets in 14 Days",
"description": "Read Hosea through Malachi",
"duration_days": 14,
"overview": "Study the twelve Minor Prophets in two weeks, exploring themes of judgment, repentance, and the coming Messiah. These shorter books pack powerful messages of God's justice and mercy.",
"days": MINOR_PROPHETS_14,
"sample_days": [
{"day": 1, "readings": ["Hosea 1-7"], "theme": "God's Faithful Love for Unfaithful Israel"},
{"day": 6, "readings": ["Obadiah", "Jonah 1-4"], "theme": "Edom's Doom, Jonah and God's Mercy"},
{"day": 9, "readings": ["Habakkuk 1-3"], "theme": "The Just Shall Live by Faith"},
{"day": 14, "readings": ["Malachi 1-4"], "theme": "Messenger of the Covenant, Elijah Returns"},
]
},
"wisdom": {
"name": "Wisdom Literature in 30 Days",
"description": "Study Job, Psalms, Proverbs, Ecclesiastes, and Song of Solomon",
"duration_days": 30,
"overview": "Immerse yourself in the wisdom books of Scripture, from Job's suffering to the praises of the Psalms, the practical wisdom of Proverbs, the philosophical reflections of Ecclesiastes, and the poetry of the Song of Solomon.",
"days": WISDOM_30,
"sample_days": [
{"day": 1, "readings": ["Job 1-3"], "theme": "Job's Suffering Begins"},
{"day": 9, "readings": ["Job 38-42"], "theme": "God Speaks, Job Restored"},
{"day": 19, "readings": ["Psalms 119"], "theme": "The Word of God"},
{"day": 27, "readings": ["Proverbs 26-31"], "theme": "Fools, Agur, The Virtuous Woman"},
{"day": 30, "readings": ["Song of Solomon 1-8"], "theme": "The Beloved and the Lover"},
]
},
"historical": {
"name": "Historical Books in 45 Days",
"description": "Read Joshua through Esther",
"duration_days": 45,
"overview": "Journey through Israel's history from conquering the Promised Land to the exile and return. Experience the judges, kings, prophets, and God's faithfulness through triumph and tragedy.",
"days": HISTORICAL_45,
"sample_days": [
{"day": 1, "readings": ["Joshua 1-5"], "theme": "Entering the Promised Land"},
{"day": 11, "readings": ["Ruth 1-4"], "theme": "Loyalty, Redemption, David's Ancestry"},
{"day": 24, "readings": ["1 Kings 5-8"], "theme": "Temple Built and Dedicated"},
{"day": 34, "readings": ["2 Kings 24-25"], "theme": "Jerusalem Falls, Exile"},
{"day": 45, "readings": ["Nehemiah 3-13", "Esther 1-10"], "theme": "Walls Rebuilt, Esther Saves Her People"},
]
},
"general-epistles": {
"name": "General Epistles in 14 Days",
"description": "Read Hebrews through Jude plus Revelation's letters",
"duration_days": 14,
"overview": "Study the non-Pauline letters of the New Testament, exploring themes of faith, perseverance, practical Christian living, and warnings against false teaching.",
"days": GENERAL_EPISTLES_14,
"sample_days": [
{"day": 1, "readings": ["Hebrews 1-4"], "theme": "Christ Superior to Angels and Moses"},
{"day": 5, "readings": ["James 1-3"], "theme": "Faith and Works, Taming the Tongue"},
{"day": 10, "readings": ["1 John 1-3"], "theme": "Fellowship, Walking in Light, Love"},
{"day": 13, "readings": ["Jude"], "theme": "Contending for the Faith"},
{"day": 14, "readings": ["Revelation 1-3"], "theme": "Vision of Christ, Letters to Churches"},
]
}
}
+91 -302
View File
@@ -223,42 +223,12 @@ 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
from .jinja_filters import register_filters
register_filters(templates.env)
# Add global template variables
templates.env.globals['disable_analytics'] = os.getenv("DISABLE_ANALYTICS", "false").lower() == "true"
# 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)
@@ -823,274 +793,6 @@ def search_family_tree(query: str, limit: Optional[int] = None) -> List[Dict]:
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 chapter view with anchor
if verse_end:
url = f'/book/{full_book}/chapter/{chapter}#verse-{verse_start}-{verse_end}'
else:
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, but NOT if followed by possessive 's
# This prevents breaking up "LORD'S" into "LORD" + "'S"
pattern = re.compile(r'\b(' + re.escape(word) + r')(?!\'[sS])\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
def _convert(pattern: str, payload: str) -> str:
markers = list(re.finditer(pattern, payload))
if len(markers) < 2:
return payload
numbers = [int(m.group(1)) for m in markers]
if numbers[0] != 1:
return payload
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 payload
markers = markers[:seq_length]
list_items = []
for i, marker in enumerate(markers):
start = marker.end()
if i + 1 < len(markers):
end = markers[i + 1].start()
else:
remaining = payload[start:]
end_match = re.search(r'[.;]\s+(?=[A-Z])|$', remaining)
end = start + end_match.start() + 1 if end_match else len(payload)
item_text = payload[start:end].strip().rstrip(';,')
if item_text.endswith(' and'):
item_text = item_text[:-4]
list_items.append(f'<li>{item_text}</li>')
html_list = '<ol>' + ''.join(list_items) + '</ol>'
list_start = markers[0].start()
last_marker = markers[-1]
last_item_start = last_marker.end()
remaining_after_last = payload[last_item_start:]
end_match = re.search(r'[.;]\s+(?=[A-Z])|$', remaining_after_last)
list_end = last_item_start + end_match.start() + 1 if end_match else len(payload)
after_list = payload[list_end:].strip()
if after_list:
return payload[:list_start] + html_list + '</p><p>' + after_list
return payload[:list_start] + html_list
# Try classic (1) pattern first to avoid false positives in verse refs
primary_pattern = r'\((\d+)\)\s*'
converted = _convert(primary_pattern, text)
if converted != text:
return converted
# Fallback: bare "1)" patterns preceded by whitespace (not verse refs)
fallback_pattern = r'(?<=\s)(\d+)\)\s*'
return _convert(fallback_pattern, text)
templates.env.filters['format_lists'] = format_numbered_lists
def number_format(value):
"""Format a number with commas (e.g., 31102 -> 31,102)"""
return f"{value:,}"
templates.env.filters['number_format'] = number_format
def linkify_strongs(text):
"""Convert Strong's references like G1234 or H5678 to links."""
import re
if not text:
return text
# Match G or H followed by digits, optionally in parentheses with Greek/Hebrew text
pattern = r'\b([GH])(\d+)\b'
def replace(match):
prefix = match.group(1)
num = match.group(2)
return f'<a href="/strongs/{prefix}{num}" class="strongs-ref">{prefix}{num}</a>'
return re.sub(pattern, replace, text)
templates.env.filters['linkify_strongs'] = linkify_strongs
def get_biblical_timeline_context():
"""
Load comprehensive biblical timeline data from JSON file.
@@ -1769,6 +1471,22 @@ async def reading_plan_detail(request: Request, plan_id: str):
if not plan:
raise HTTPException(status_code=404, detail="Reading plan not found")
# For plans 90 days or less, include full Bible text
include_text = plan.get('duration_days', 365) <= 90
days_with_text = None
if include_text:
all_days = plan.get('days') or plan.get('sample_days', [])
days_with_text = []
for day in all_days:
day_data = {
'day': day['day'],
'theme': day.get('theme', ''),
'readings': day['readings'],
'text': get_reading_text(day['readings'])
}
days_with_text.append(day_data)
breadcrumbs = [
{"text": "Home", "url": "/"},
{"text": "Reading Plans", "url": "/reading-plans"},
@@ -1784,11 +1502,62 @@ async def reading_plan_detail(request: Request, plan_id: str):
"books": books,
"breadcrumbs": breadcrumbs,
"pdf_available": WEASYPRINT_AVAILABLE,
"pdf_url": f"/reading-plans/{plan_id}/pdf" if WEASYPRINT_AVAILABLE else None
"pdf_url": f"/reading-plans/{plan_id}/pdf" if WEASYPRINT_AVAILABLE else None,
"include_text": include_text,
"days_with_text": days_with_text
}
)
def parse_reading_reference(ref: str) -> list:
"""Parse a reading reference like 'Genesis 1-3' or 'Matthew 1' into chapter list.
Returns list of tuples: [(book, chapter), ...]
"""
# Handle patterns like "Genesis 1-3", "Matthew 1", "1 John 2-3"
# Pattern: optional number prefix + book name + chapter range
pattern = r'^((?:\d\s+)?[A-Za-z]+(?:\s+[A-Za-z]+)?)\s+(\d+)(?:-(\d+))?$'
match = re.match(pattern, ref.strip())
if not match:
return []
book = match.group(1)
start_ch = int(match.group(2))
end_ch = int(match.group(3)) if match.group(3) else start_ch
# Normalize book name - if it's already canonical, use it as-is
normalized = normalize_book_name(book)
if not normalized:
# Check if it's already a valid canonical name
all_books = OT_BOOKS + NT_BOOKS
if book in all_books:
normalized = book
else:
return []
return [(normalized, ch) for ch in range(start_ch, end_ch + 1)]
def get_reading_text(readings: list) -> list:
"""Get the Bible text for a list of reading references.
Returns list of dicts with book, chapter, and verses.
"""
result = []
for ref in readings:
chapters = parse_reading_reference(ref)
for book, chapter in chapters:
verses = bible.get_verses_by_book_chapter(book, chapter)
if verses:
result.append({
'book': book,
'chapter': chapter,
'verses': verses,
'reference': f"{book} {chapter}"
})
return result
@app.get("/reading-plans/{plan_id}/pdf")
async def reading_plan_pdf(plan_id: str):
"""Generate a PDF export for a reading plan."""
@@ -1802,7 +1571,27 @@ async def reading_plan_pdf(plan_id: str):
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)
# For plans 90 days or less, include full Bible text (excludes 365-day plans)
include_text = plan.get('duration_days', 365) <= 90
days_with_text = None
if include_text:
all_days = plan.get('days') or plan.get('sample_days', [])
days_with_text = []
for day in all_days:
day_data = {
'day': day['day'],
'theme': day.get('theme', ''),
'readings': day['readings'],
'text': get_reading_text(day['readings'])
}
days_with_text.append(day_data)
html_content = templates.get_template("reading_plan_pdf.html").render(
plan=plan,
include_text=include_text,
days_with_text=days_with_text
)
pdf_buffer = await render_html_to_pdf_async(html_content)
filename = f"reading-plan-{plan_id}.pdf"
+196 -45
View File
@@ -15,43 +15,6 @@
background: var(--code-bg);
}
.sample-days {
max-width: 70%;
margin: 2rem 0;
}
.day-entry {
padding: 1rem;
margin: 1rem 0;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.day-number {
font-weight: 600;
color: var(--link-color);
font-size: 1.1rem;
}
.day-readings {
margin: 0.5rem 0;
}
.reading-ref {
display: inline-block;
margin: 0.25rem 0.5rem 0.25rem 0;
padding: 0.25rem 0.5rem;
background: var(--code-bg);
border-radius: 3px;
font-size: 0.95rem;
}
.day-theme {
font-style: italic;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.plan-stats {
display: flex;
gap: 2rem;
@@ -116,12 +79,157 @@
height: 16px;
}
/* Day navigation */
.day-nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.5rem 0;
padding: 1rem;
background: var(--code-bg);
border-radius: 4px;
max-width: 80%;
}
.day-nav a {
display: inline-block;
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
color: var(--text-secondary);
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 3px;
text-decoration: none;
transition: all 0.15s;
}
.day-nav a:hover {
border-color: var(--link-color);
color: var(--link-color);
}
/* Day entries with scripture */
.reading-day {
margin: 2.5rem 0;
padding-bottom: 2rem;
border-bottom: 2px solid var(--border-color);
}
.day-header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.75rem;
}
.day-number {
font-size: 1.4rem;
font-weight: 600;
color: var(--link-color);
}
.day-theme {
font-style: italic;
color: var(--text-secondary);
font-size: 1.1rem;
}
.day-readings-summary {
margin-bottom: 1rem;
font-size: 0.95rem;
color: var(--text-secondary);
}
.day-readings-summary span {
display: inline-block;
margin-right: 0.5rem;
padding: 0.2rem 0.5rem;
background: var(--code-bg);
border-radius: 3px;
}
/* Scripture text display */
.scripture-content {
margin-top: 1rem;
}
.chapter-section {
margin: 1.5rem 0;
}
.chapter-heading {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border-color);
}
.chapter-heading a {
color: inherit;
text-decoration: none;
}
.chapter-heading a:hover {
color: var(--link-color);
}
.verse-text {
line-height: 1.85;
text-align: justify;
}
.verse-num {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.8rem;
vertical-align: super;
margin-right: 0.1rem;
}
/* Reference-only view (for 365-day plans) */
.sample-days {
max-width: 70%;
margin: 2rem 0;
}
.day-entry {
padding: 1rem;
margin: 1rem 0;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.reading-ref {
display: inline-block;
margin: 0.25rem 0.5rem 0.25rem 0;
padding: 0.25rem 0.5rem;
background: var(--code-bg);
border-radius: 3px;
font-size: 0.95rem;
}
@media print {
.plan-actions,
.print-btn {
.print-btn,
.day-nav {
display: none;
}
}
@media (max-width: 768px) {
.plan-overview,
.plan-stats,
.intro-text,
.sample-days {
max-width: 100%;
}
.day-nav {
max-width: 100%;
}
}
</style>
{% endblock %}
@@ -135,7 +243,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download Plan (PDF)
Download PDF{% if include_text %} (with Scripture text){% endif %}
</a>
</div>
{% endif %}
@@ -157,6 +265,53 @@
</div>
</section>
{% if include_text and days_with_text %}
{# Full text version with scripture #}
<section>
<h2>Reading Schedule</h2>
<p class="intro-text">Jump to any day:</p>
<div class="day-nav">
{% for day in days_with_text %}
<a href="#day-{{ day.day }}">{{ day.day }}</a>
{% endfor %}
</div>
</section>
{% for day in days_with_text %}
<section class="reading-day" id="day-{{ day.day }}">
<div class="day-header">
<span class="day-number">Day {{ day.day }}</span>
{% if day.theme %}<span class="day-theme">{{ day.theme }}</span>{% endif %}
</div>
<div class="day-readings-summary">
{% for reading in day.readings %}
<span>{{ reading }}</span>
{% endfor %}
</div>
{% if day.text %}
<div class="scripture-content">
{% for section in day.text %}
<div class="chapter-section">
<div class="chapter-heading">
<a href="/book/{{ section.book }}/chapter/{{ section.chapter }}">{{ section.reference }}</a>
</div>
<div class="verse-text">
{% for verse in section.verses %}
<sup class="verse-num">{{ verse.verse }}</sup>{{ verse.text }}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</section>
{% endfor %}
{% else %}
{# Reference-only version for longer plans #}
<section>
<h2>Complete Reading Schedule</h2>
<p class="intro-text">All {{ plan.duration_days }} days of readings for this plan.</p>
@@ -173,7 +328,6 @@
{% if ref_parts|length >= 2 %}
{% set chapter_verse = ref_parts[-1] %}
{% if ':' in chapter_verse %}
{# Format: "Book Chapter:Verse" or "Book Chapter:Verse-Verse" #}
{% set chapter = chapter_verse.split(':')[0] %}
{% set verse_part = chapter_verse.split(':')[1] %}
{% if '-' in verse_part %}
@@ -184,18 +338,15 @@
{% set book = ' '.join(ref_parts[:-1]) %}
<a href="/book/{{ book }}/chapter/{{ chapter }}/verse/{{ verse_num }}">{{ reading }}</a>
{% elif '-' in chapter_verse %}
{# Format: "Book Chapter-Chapter" (e.g., "Psalm 1-3") #}
{% set first_chapter = chapter_verse.split('-')[0] %}
{% set book = ' '.join(ref_parts[:-1]) %}
<a href="/book/{{ book }}/chapter/{{ first_chapter }}">{{ reading }}</a>
{% else %}
{# Format: "Book Chapter" (single chapter) #}
{% set chapter = ref_parts[-1] %}
{% set book = ' '.join(ref_parts[:-1]) %}
<a href="/book/{{ book }}/chapter/{{ chapter }}">{{ reading }}</a>
{% endif %}
{% else %}
{# Fallback: just display as text #}
{{ reading }}
{% endif %}
</span>
@@ -206,6 +357,7 @@
{% endfor %}
</div>
</section>
{% endif %}
<section>
<h2>How to Use This Plan</h2>
@@ -222,8 +374,7 @@
<script>
(function() {
// Include overview, intro text, day entries, and PDF button
const elements = Array.from(document.querySelectorAll('.plan-overview, .intro-text, .day-entry, .print-btn'));
const elements = Array.from(document.querySelectorAll('.plan-overview, .intro-text, .reading-day, .day-entry, .print-btn'));
if (elements.length === 0) return;
let selectedIndex = -1;
+82 -6
View File
@@ -41,14 +41,29 @@
}
.day-entry {
margin-bottom: 0.2in;
padding-bottom: 0.15in;
border-bottom: 1px solid #eee;
margin-bottom: 0.3in;
padding-bottom: 0.2in;
border-bottom: 1px solid #ddd;
page-break-inside: avoid;
}
.day-header {
background: #f5f5f5;
padding: 0.1in 0.15in;
margin-bottom: 0.15in;
border-radius: 3px;
}
.day-number {
font-weight: 600;
color: #444;
color: #333;
font-size: 12pt;
}
.day-theme {
font-style: italic;
color: #666;
margin-left: 0.2in;
}
.day-readings {
@@ -64,9 +79,35 @@
font-size: 10pt;
}
.day-theme {
font-style: italic;
/* Scripture text styling */
.scripture-section {
margin-top: 0.15in;
}
.chapter-heading {
font-size: 13pt;
font-weight: 600;
color: #444;
margin: 0.2in 0 0.1in 0;
padding-bottom: 0.05in;
border-bottom: 1px solid #eee;
}
.verse {
margin: 0.03in 0;
text-align: justify;
}
.verse-num {
font-weight: 600;
color: #666;
font-size: 9pt;
vertical-align: super;
margin-right: 0.03in;
}
.verse-text {
/* inline with verse number */
}
.footer {
@@ -76,6 +117,15 @@
text-align: center;
}
/* Page breaks */
.day-entry-with-text {
page-break-after: always;
}
.day-entry-with-text:last-child {
page-break-after: avoid;
}
/* Style sidenotes as inline notes for PDF */
.margin-toggle,
.sidenote-number,
@@ -110,6 +160,31 @@
<div class="overview">{{ plan.overview | safe }}</div>
{% if include_text and days_with_text %}
{# Full text version for shorter plans #}
{% for day in days_with_text %}
<div class="day-entry day-entry-with-text">
<div class="day-header">
<span class="day-number">Day {{ day.day }}</span>
{% if day.theme %}<span class="day-theme">{{ day.theme }}</span>{% endif %}
</div>
<div class="scripture-section">
{% for section in day.text %}
<div class="chapter-heading">{{ section.reference }}</div>
{% for verse in section.verses %}
<span class="verse">
<span class="verse-num">{{ verse.verse }}</span>
<span class="verse-text">{{ verse.text }}</span>
</span>
{% endfor %}
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
{# Reference-only version for longer plans #}
{% set all_days = plan.days if plan.days else plan.sample_days %}
{% for day in all_days %}
<div class="day-entry">
@@ -122,6 +197,7 @@
<div class="day-theme">Theme: {{ day.theme }}</div>
</div>
{% endfor %}
{% endif %}
<div class="footer">From KJV Study &bull; kjvstudy.org</div>
</body>