tags for better navigation"""
+ if not text:
+ return text
+ parts = re.split(r'\n\n| \s* ', text)
+ paragraphs = [f'
{p.strip()}
' 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'{prefix}{num}'
+ 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
diff --git a/kjvstudy_org/reading_plans.py b/kjvstudy_org/reading_plans.py
index c0e8bd1..888b867 100644
--- a/kjvstudy_org/reading_plans.py
+++ b/kjvstudy_org/reading_plans.py
@@ -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"},
+ ]
}
}
diff --git a/kjvstudy_org/server.py b/kjvstudy_org/server.py
index 9e65510..1d34a27 100644
--- a/kjvstudy_org/server.py
+++ b/kjvstudy_org/server.py
@@ -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
tags that mistune might add
- html = _inline_markdown(text).strip()
- if html.startswith('
') and html.endswith('
'):
- html = html[3:-4]
- return html
-
-def markdown_to_html(text):
- """Convert markdown to HTML (bold, italic, paragraphs, etc.)."""
- if not text:
- return text
- return _markdown(text).strip()
-
-templates.env.filters['md'] = markdown_to_html
-templates.env.filters['mdi'] = markdown_inline
-
# Initialize templates for route modules
init_api_templates(templates)
init_resources_templates(templates)
@@ -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'{matched_text}'
-
- text = re.sub(verse_pattern, verse_replace_callback, text)
-
- # Then, link person names to family tree
- name_to_id = get_person_name_mapping()
-
- if not name_to_id:
- return text
-
- # Sort names by length (longest first) to handle multi-word names correctly
- sorted_names = sorted(name_to_id.keys(), key=len, reverse=True)
-
- # Process each name
- for name_lower in sorted_names:
- person_id = name_to_id[name_lower]
-
- # Create a case-insensitive regex pattern with word boundaries
- # This will match the name but not if it's inside an HTML tag
- name_pattern = re.escape(name_lower)
-
- # Use a callback function to avoid replacing text inside HTML tags
- def replace_callback(match):
- matched_text = match.group(0)
- # Check if this match is inside an HTML tag
- start_pos = match.start()
-
- # Look backwards to see if we're inside a tag
- text_before = text[:start_pos]
- last_lt = text_before.rfind('<')
- last_gt = text_before.rfind('>')
-
- # If the last '<' is more recent than the last '>', we're inside a tag
- if last_lt > last_gt:
- return matched_text
-
- # Also check if we're inside an href or other attribute
- if last_lt != -1:
- tag_content = text[last_lt:start_pos]
- if 'href=' in tag_content or 'src=' in tag_content:
- return matched_text
-
- # Safe to link
- return f'{matched_text}'
-
- # Use word boundaries and case-insensitive matching
- pattern = r'\b' + name_pattern + r'\b'
- text = re.sub(pattern, replace_callback, text, flags=re.IGNORECASE)
-
- return text
-
-
-# Register the custom Jinja2 filter for linking person names in templates
-templates.env.filters['link_names'] = link_person_names_in_text
-
-
-def link_verse_references_in_text(text):
- """Automatically link verse references in text (e.g., 'Genesis 1:1', 'Hebrews 9:22')"""
- if not text:
- return text
-
- # Pattern to match verse references like "Genesis 1:1", "1 Corinthians 5:7", "Romans 4:3"
- # Matches: BookName Chapter:Verse or BookName Chapter:Verse-Verse
- pattern = r'\b((?:1|2|3)\s+)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(\d+):(\d+)(?:-(\d+))?\b'
-
- def replace_reference(match):
- number_prefix = match.group(1) or '' # "1 ", "2 ", "3 " or empty
- book_name = match.group(2) # "Corinthians", "Kings", "Genesis"
- chapter = match.group(3)
- verse_start = match.group(4)
- verse_end = match.group(5) # Could be None if no range
-
- # Construct full book name
- full_book = (number_prefix + book_name).strip()
- full_reference = match.group(0)
-
- # Check if this match is inside an HTML tag
- start_pos = match.start()
- text_before = text[:start_pos]
- last_lt = text_before.rfind('<')
- last_gt = text_before.rfind('>')
-
- # If inside a tag, don't replace
- if last_lt > last_gt:
- return full_reference
-
- # Also check if we're inside an href or other attribute
- if last_lt != -1:
- tag_content = text[last_lt:start_pos]
- if 'href=' in tag_content or 'src=' in tag_content:
- return full_reference
-
- # Create link to 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'{full_reference}'
-
- return re.sub(pattern, replace_reference, text)
-
-
-# Register the verse reference linking filter
-templates.env.filters['link_verses'] = link_verse_references_in_text
-
-
-def inject_word_markers(text, word_studies, verse_num):
- """Inject sidenote markers into verse text next to annotated words"""
- if not word_studies:
- return text
-
- # Process each word study
- for idx, study in enumerate(word_studies, 1):
- word = study['word']
- # Create the sidenote marker HTML
- marker = f'{word}: {study["term"]} ({study["translit"]}). {study["note"]}'
-
- # Find and replace the word with word + marker
- # Use a more precise replacement to avoid replacing partial matches
- import re
- # Match the word with word boundaries, 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'