Frontend Features and refactor

This commit is contained in:
2026-10-19 15:10:59 -07:00
parent 7a0dbe19ea
commit 317f5bf746
28 changed files with 3915 additions and 530 deletions
+656 -118
View File
@@ -19,6 +19,12 @@ import sys
import logging
import socket
import json
import hashlib
import re
import difflib
import math
import urllib.parse
import html as html_lib
from tools import sanity_check
try:
from dotenv import load_dotenv
@@ -75,6 +81,277 @@ def dict_factory(cursor, row):
kjv_cur = None
sermon_cur = None
# ---------------------------------------------------------------------------
# Verse menu helpers (favorites, definitions and cross references are saved by
# the browser itself; the server only supplies the lookups)
# ---------------------------------------------------------------------------
DICT_STOPWORDS = {
"the", "and", "that", "for", "him", "his", "her", "but", "not", "with", "are",
"was", "were", "unto", "thou", "thee", "thy", "ye", "you", "all", "from", "they",
"them", "this", "have", "had", "shall", "will", "which", "into", "upon", "then",
}
def format_verse_ref(verse_id) -> str:
"""12345678 style id -> 'John 3:16'"""
verse_id = int(verse_id)
return f"{lookup_bookname(verse_id // 1_000_000)} {verse_id // 1000 % 1000}:{verse_id % 1000}"
@app.get("/xref/<int:verse_id>") # cross references for one verse, best ranked first
def cross_references(verse_id: int):
rows = kjv_cur.execute(
"SELECT sv, ev FROM cross_reference WHERE vid = ? ORDER BY r DESC LIMIT 15",
(verse_id,),
).fetchall()
results = []
for start_id, end_id in rows:
ref = format_verse_ref(start_id)
if end_id and end_id > start_id:
end_ref = format_verse_ref(end_id)
# "John 3:16-18" when the range stays in one chapter
same_chapter = start_id // 1000 == end_id // 1000
ref += "-" + (str(end_id % 1000) if same_chapter else end_ref)
verses = lookup_by_verse_id(start_id, end_id)
else:
verses = lookup_by_verse_id(start_id)
results.append({
"ref": ref,
"verse_id": start_id,
"text": " ".join(v[4] for v in verses),
})
return jsonify(results)
@app.get("/defs") # 1828 Webster definitions for several words at once: /defs?words=grace,loved
def definitions():
words = request.args.get("words", "").lower().replace(",", " ").split()[:40]
dictionary = cached["json_dictionary"]
found = {}
for word in words:
word = "".join(c for c in word if c.isalpha())
if word and word not in DICT_STOPWORDS and word in dictionary:
found[word] = dictionary[word]
return jsonify(found)
@app.get("/saved") # favorites, definitions and cross references kept in this browser
def saved_page():
return static_page("html/saved.html")
# ---------------------------------------------------------------------------
# Bible reading plan: a /seq style tracker that can go backwards. The plan is
# its UID; the page URL (/plan/<uid>?n=<verses per day>) is what gets bookmarked.
# ---------------------------------------------------------------------------
PLAN_UID_CHARS = "0123456789ACEFHJKLMNPRTUVWXY"
PLAN_MAX_PER_DAY = 500
def plan_connection():
if usermode == "Single":
conn = sqlite3.connect("data/seq.db", check_same_thread=False)
else:
conn = sqlite3.connect("data/seq.db")
conn.execute(
"CREATE TABLE IF NOT EXISTS reading_tracker (uid TEXT PRIMARY KEY, verse INTEGER, last_used TEXT)"
)
return conn
def plan_per_day(raw, default=25) -> int:
try:
return max(1, min(PLAN_MAX_PER_DAY, int(raw)))
except (TypeError, ValueError):
return default
def plan_start_index(ref: str):
"""Index into verse_ids for a reference like 'Matthew 1:1'; None if it can't be parsed."""
ref = (ref or "").strip()
if not ref:
return 0
try:
verse_id = int(show(kw=ref, get_start_verse_id=True))
return cached["verse_ids"].index(verse_id)
except Exception:
return None
def plan_error(message: str):
return static_html_doc(
"Reading Plan",
f"<h2>Reading Plan</h2><p>{html_lib.escape(message)}</p><p><a href='/plan'>Back to plan setup</a></p>",
), 400
@app.get("/plan") # setup form for a new reading plan
def reading_plan():
return static_page("html/plan.html")
@app.post("/plan/new")
def plan_create():
total = len(cached["verse_ids"])
start = plan_start_index(request.form.get("start", ""))
if start is None:
return plan_error("We couldn't find that starting point. Try something like 'Matthew 1:1'.")
if request.form.get("pace") == "verses":
per_day = plan_per_day(request.form.get("verses"))
else:
try:
days = max(1, min(3650, int(request.form.get("days", 365))))
except ValueError:
days = 365
per_day = plan_per_day(math.ceil((total - start) / days))
conn = plan_connection()
for _ in range(5): # a clash on a random 5 character id is very unlikely
uid = generate_short_id()
try:
conn.execute(
"INSERT INTO reading_tracker VALUES(?, ?, ?)", (uid, start, str(datetime.now()))
)
break
except sqlite3.IntegrityError:
continue
else:
return plan_error("Could not create a plan, please try again.")
conn.commit()
conn.close()
logging.info(f"New reading plan {uid} start={start} per_day={per_day}")
return redirect(f"/plan/{uid}?n={per_day}", code=303)
def plan_uid_or_none(raw: str):
uid = search_input_cleaning(raw, valid_chars=PLAN_UID_CHARS)
return uid if uid and uid == raw else None
@app.post("/plan/<uid>/move") # back / next / jump; POST so a page prefetch can never move your place
def plan_move(uid: str):
uid = plan_uid_or_none(uid)
per_day = plan_per_day(request.form.get("n"))
if not uid:
return plan_error("That reading plan id is not valid.")
conn = plan_connection()
row = conn.execute("SELECT verse FROM reading_tracker WHERE uid = ?", (uid,)).fetchone()
if row is None:
conn.close()
return plan_error("That reading plan was not found (plans unused for 90 days are removed).")
total = len(cached["verse_ids"])
position = row[0]
action = request.form.get("action")
if action == "next":
position = min(total, position + per_day)
elif action == "back":
position = max(0, position - per_day)
elif action == "restart":
position = 0
elif action == "goto":
target = plan_start_index(request.form.get("kw", ""))
if target is None:
conn.close()
return plan_error("We couldn't find that reference. Try something like 'John 3:1'.")
position = target
conn.execute(
"REPLACE INTO reading_tracker (uid, verse, last_used) VALUES(?, ?, ?)",
(uid, position, str(datetime.now())),
)
conn.commit()
conn.close()
return redirect(f"/plan/{uid}?n={per_day}", code=303)
@app.get("/plan/<uid>") # today's reading for a plan. Viewing never moves your place.
def plan_view(uid: str):
uid = plan_uid_or_none(uid)
if not uid:
return plan_error("That reading plan id is not valid.")
per_day = plan_per_day(request.args.get("n"))
conn = plan_connection()
row = conn.execute("SELECT verse FROM reading_tracker WHERE uid = ?", (uid,)).fetchone()
if row is None:
conn.close()
return plan_error("That reading plan was not found (plans unused for 90 days are removed).")
position = row[0]
# keeps an active plan from being purged as unused
conn.execute(
"REPLACE INTO reading_tracker (uid, verse, last_used) VALUES(?, ?, ?)",
(uid, position, str(datetime.now())),
)
conn.commit()
conn.close()
verse_ids = cached["verse_ids"]
total = len(verse_ids)
finished = position >= total
last_index = min(total, position + per_day) - 1
percent = round(100 * min(position, total) / total, 1)
esc = html_lib.escape
controls = f"""<form method='post' action='/plan/{uid}/move' class='plan-form'>
<input type='hidden' name='n' value='{per_day}'>"""
back_disabled = "disabled" if position <= 0 else ""
next_html = (
"" if finished else
"<button type='submit' name='action' value='next' class='plan-primary'>Done &mdash; next reading &rarr;</button>"
)
controls += f"""{next_html}
<button type='submit' name='action' value='back' class='plan-secondary' {back_disabled}>&larr; Previous reading</button>
</form>
<form method='post' action='/plan/{uid}/move' class='plan-form plan-goto'>
<input type='hidden' name='n' value='{per_day}'>
<input type='hidden' name='action' value='goto'>
<label for='goto-kw'>Jump to</label>
<div class='plan-goto-row'><input type='text' id='goto-kw' name='kw' data-book-suggest placeholder='e.g. Psalms 23:1' autocapitalize='off' autocomplete='off'>
<button type='submit' class='plan-secondary'>Go</button></div>
</form>"""
if finished:
summary = (
"<div class='plan-summary'><strong>You've finished the plan!</strong>"
f"<div>You read all {total:,} verses. Use Previous reading to look back, or start over.</div></div>"
)
verses_html = ""
controls += f"""<form method='post' action='/plan/{uid}/move' class='plan-form'>
<input type='hidden' name='n' value='{per_day}'>
<button type='submit' name='action' value='restart' class='plan-secondary'>Start over from Genesis 1:1</button></form>"""
else:
first_ref = format_verse_ref(verse_ids[position])
last_ref = format_verse_ref(verse_ids[last_index])
count = last_index - position + 1
summary = (
"<div class='plan-summary'><strong>Today's reading</strong>"
f"<div class='plan-range'>{esc(first_ref)} &ndash; {esc(last_ref)}</div>"
f"<div class='plan-meta'>{count} verses &middot; {percent}% of the Bible complete</div>"
f"<div class='plan-progress' role='img' aria-label='{percent}% complete'><span style='width:{percent}%'></span></div></div>"
)
verses = lookup_by_verse_id(verse_ids[position], verse_ids[last_index])
verses_html = "".join(response_rich(v) for v in verses)
bookmark_note = (
"<div class='plan-note'>Bookmark this page &mdash; it is your reading plan. "
"Come back each day, then press <em>Done</em> to move on.</div>"
)
store_script = (
"<script>try{localStorage.setItem('kjv.plan',JSON.stringify("
f"{{uid:'{uid}',n:{per_day},t:Date.now()}}))}}catch(e){{}}</script>"
)
return (
render_navbar("", bar_html="")
+ "<div class='responses'>"
+ summary
+ verses_html
+ f"<div class='plan-controls'>{controls}</div>"
+ bookmark_note
+ store_script
+ "</div>"
)
if __name__ == "__main__":
# single user mode, running without uwsgi
usermode: str = "Single"
@@ -117,6 +394,20 @@ for group in theological_synonym_groups:
concept_mappings = json.load(open("data/concept_mappings.json", "r"))
concept_mappings_dict = {mapping["query"].lower(): mapping for mapping in concept_mappings}
# Static assets are served with a very long cache lifetime, so their URLs carry a
# hash of their contents ("?v=...") and change whenever the files do.
ASSET_VERSION: str = hashlib.md5(
b"".join(
open(f, "rb").read()
for f in ("styles/page.css", "js/collapsible.js", "js/shortcode.js", "js/verse_menu.js", "js/book_suggest.js")
)
).hexdigest()[:8]
def load_html(path: str) -> str:
return open(path, "r").read().replace("__V__", ASSET_VERSION)
cached: dict = {
"verse_ids": [i[0] for i in kjv_cur.execute("SELECT id FROM fts_kjv;").fetchall()],
"book_names": [
@@ -129,19 +420,29 @@ cached: dict = {
"theological_synonyms": theological_synonyms_map,
"concept_mappings": concept_mappings_dict,
"html_response": open("html/response.html", "r").read(),
"html_navbar": open("html/navbar.html", "r").read(),
"html_navbar": load_html("html/navbar.html"),
"book_descriptions": json.load(open("data/book_descriptions.json", "r")),
"chapter_summaries": json.load(open("data/chapter_summaries.json", "r")),
"html_chapter_nav": open("html/chapter_nav.html", "r").read(),
"pages": {},
"css_navbar": open("styles/header.css", "r").read(),
"css_response": open("styles/human_readable.css", "r").read(),
"css_collapsible": open("styles/collapsible.css", "r").read(),
"css_page": open("styles/page.css", "r").read(),
"js_collapsible": open("js/collapsible.js", "r").read(),
"js_shortcode": open("js/shortcode.js", "r").read(),
"js_verse_menu": open("js/verse_menu.js", "r").read(),
"favicon_svg": open("static/favicon.svg", "r").read(),
"book_info": kjv_cur.execute(
'SELECT "order", title_short, category, otnt, chapters FROM book_info ORDER BY "order"'
).fetchall(),
}
# Book name suggestions: the list of names is baked into the script once
cached["js_book_suggest"] = open("js/book_suggest.js", "r").read().replace(
"__BOOKS__", json.dumps(cached["book_names"])
)
# Fill the chapter list with unique chapters for seeking back and forward by index.
[
cached["chapter_list"].append(int(str(chap)[:-3]))
@@ -166,14 +467,16 @@ for book in cached["book_info"]:
book_order, title, category, testament, num_chapters = book
testament_data[testament][category].append({
"title": title,
"chapters": num_chapters
"chapters": num_chapters,
"about": cached["book_descriptions"].get(title, ""),
"summaries": cached["chapter_summaries"].get(title, []),
})
cached["book_info_for_index"] = dict(testament_data)
# Render the index page once and cache it
with app.app_context():
from flask import render_template_string
template_content = open("html/index.html", "r").read()
template_content = load_html("html/index.html")
cached["html_index"] = render_template_string(template_content, testament_data=cached["book_info_for_index"])
@app.get("/js/shortcode.js")
@@ -182,6 +485,18 @@ def serve_shortcode_js():
response.headers['Cache-Control'] = 'public, max-age=31536000'
return response
@app.get("/js/book_suggest.js")
def serve_book_suggest_js():
response = Response(cached.get("js_book_suggest"), mimetype='application/javascript')
response.headers['Cache-Control'] = 'public, max-age=31536000'
return response
@app.get("/js/verse_menu.js")
def serve_verse_menu_js():
response = Response(cached.get("js_verse_menu"), mimetype='application/javascript')
response.headers['Cache-Control'] = 'public, max-age=31536000'
return response
@app.get("/js/collapsible.js")
def serve_collapsible_js():
response = Response(cached.get("js_collapsible"), mimetype='application/javascript')
@@ -201,6 +516,49 @@ def serve_favicon():
response.headers['Cache-Control'] = 'public, max-age=31536000'
return response
def static_page(path: str):
"""Serve one of the simple html/ pages with its asset URLs versioned."""
if path not in cached["pages"]:
cached["pages"][path] = load_html(path)
return Response(cached["pages"][path], mimetype="text/html")
def static_html_doc(title: str, body: str):
"""Wrap a small html snippet in the standard page shell."""
return (
"<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
f"<title>{title} - KJV API</title>"
f"<link rel='stylesheet' href='/styles/page.css?v={ASSET_VERSION}'></head><body>"
"<header><a href='/'>Home</a> <a href='/find'>Quick Search</a></header>"
f"{body}</body></html>"
)
def chapter_find_link(name: str) -> str:
"""Same link the verse headers use: the whole chapter, in the rich view."""
return "/find?kw=" + urllib.parse.quote(name) + "&view=rich"
def render_navbar(query: str = "", chapter_nav: dict = None, bar_html: str = "") -> str:
"""Opens the rich html document: <head>, site header and the chapter bar.
The caller adds the body content after it. bar_html replaces the chapter
bar for pages (like the reading plan) that bring their own.
"""
esc = lambda value: html_lib.escape(str(value), quote=True)
query = query or ""
title = esc(query.strip() or (chapter_nav or {}).get("current_chapter_name") or "Scripture")
chapter_html = bar_html
if chapter_nav:
values = {key: esc(value) for key, value in chapter_nav.items()}
values["current_chapter_link"] = esc(chapter_find_link(chapter_nav["current_chapter_name"]))
chapter_html = cached["html_chapter_nav"].format(**values)
return cached["html_navbar"].format(
title=title, css=cached["css_navbar"], query=esc(query), chapter_nav=chapter_html
)
def generate_short_id():
remain = random.randrange(614_656, 17_210_367) # resolves to length of 5
chars = "0123456789ACEFHJKLMNPRTUVWXY"
@@ -511,27 +869,36 @@ def parse_query(query_override: str = None):
return jsonify({"Error": "/find endpoint only supports rich responses"})
logging.info(f"Query: {query}")
if not query:
return send_file("html/find.html")
return static_page("html/find.html")
if query.endswith(","):
query = query[:-1]
# Check if query contains both letters and digits (required for reference parsing)
# If only numbers or only letters, skip reference parsing and go to keyword search
has_letters = any(char.isalpha() for char in query)
has_digits = any(char.isdigit() for char in query)
if not (has_letters and has_digits):
result = search()
else:
# A query that reads as scripture references ("Exodus", "1 jn 3.16") is shown
# as scripture; anything else is a keyword search.
resolved = resolve_references(query)
if resolved:
result = show(kw=query, human_readable=human_readable)
if not hasattr(result, "__len__"):
logging.debug(f"parse_query.show = '{result}'")
if isinstance(result, str) and result != "Invalid Query":
notices = ""
for original, name in resolved["notes"]:
notices += (
f"<div class='correction-notice'>Showing <strong>{html_lib.escape(name)}</strong> "
f"(interpreted from '{html_lib.escape(original)}')</div>"
)
if resolved["bare"]:
keyword = urllib.parse.quote_plus(search_input_cleaning(query))
notices += (
f"<div class='correction-notice'>Showing <strong>{html_lib.escape(resolved['label'])}</strong>, "
f"chapter 1. Looking for the word instead? "
f"<a href='/search?kw={keyword}&view=rich'>Search the text for '{html_lib.escape(query)}'</a></div>"
)
if notices:
result = result.replace("<div class='responses'>", "<div class='responses'>" + notices, 1)
return result
if len(result) > 17:
return result
# Reference parsing failed, fall back to keyword search
result = search()
if not search_input_cleaning(query).strip():
# nothing searchable left (punctuation, accents): show the search form
return static_page("html/find.html")
result = search()
# Remove the closing </div> from search() so we can add dictionary content inside .responses
if result.endswith("</div>"):
result = result[:-6] # Remove last </div>
@@ -561,7 +928,7 @@ def parse_query(query_override: str = None):
result += "<style>" + cached["css_collapsible"] + "</style>"
result += "<div class='dictionary-section'><h3>1828 Webster Definitions</h3><p>Click on the word to get the definition.</p></div>"
result += definitions_html
result += "<script src='/js/collapsible.js'></script>"
result += f"<script src='/js/collapsible.js?v={ASSET_VERSION}'></script>"
result += "</div>"
return result
@@ -575,7 +942,7 @@ def define(word=None):
if ref:
ref = ref.strip()
if not ref:
return send_file("html/dictionary.html")
return static_page("html/dictionary.html")
dictionary = cached["json_dictionary"]
ref = ref.lower()
if not ref in dictionary.keys():
@@ -609,19 +976,249 @@ def return_dict_plain(ref, defs):
def return_dict_rich(ref, defs, simple):
if simple:
css = ""
head = ""
else:
css = cached["css_response"]
# standalone page: needs its own document head so phones scale it properly
css = ""
head = (
"<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<meta name='color-scheme' content='dark light'>"
f"<title>{html_lib.escape(ref.capitalize())} - KJV API</title>"
+ cached["css_navbar"]
+ "</head><body><div class='responses'>"
)
response = ""
for e in defs:
response += e + "<br><br>"
return (
cached["html_response"].format(
chapter=ref.split(":")[0], passage=ref.capitalize(), text=response
head
+ cached["html_response"].format(
chapter=ref.split(":")[0], passage=ref.capitalize(), text=response, verse_id=""
)
+ css
)
# ---------------------------------------------------------------------------
# Scripture reference resolver. Understands the many ways people type a
# reference ("1 jn 3.16", "II Kings 2:1-4", "song of songs 2", "Exodus ") and
# turns it into ranges of verse ids (book*1000000 + chapter*1000 + verse).
# ---------------------------------------------------------------------------
# Extra spellings people use that the abbreviations table does not know.
EXTRA_BOOK_ALIASES = {
"Song of Solomon": ["song of songs", "songs of solomon", "songs of songs", "song of sol",
"song of sol", "canticles", "canticle of canticles", "sos", "sng", "ss"],
"Psalms": ["psalm", "psalter", "pslm", "psa", "pss", "psm", "ps"],
"Revelation": ["revelations", "revelation of john", "revelation of st john", "the revelation", "apocalypse"],
"Ecclesiastes": ["ecclesiasts", "eccles", "ecc", "qoheleth"],
"Philemon": ["phlm", "phm", "philem"],
"Judges": ["jdg", "jdgs", "jg"],
"Mark": ["mrk", "mk", "mr"],
"John": ["jhn", "jn"],
"Acts": ["acts of the apostles", "act"],
"James": ["jas", "jm"],
}
# Words that are books but also ordinary search words: a bare "numbers" or
# "mark" still shows the book, but /find offers a keyword search as well.
BARE_BOOK_EXTRA = {
"psalm", "songofsongs", "songsofsolomon", "songofsol", "canticles", "canticleofcanticles",
"revelations", "revelationofjohn", "therevelation",
}
ROMAN_PREFIX = r"(?:iii|ii|i)(?=[\s.])"
ORDINAL_PREFIX = r"(?:1st|2nd|3rd|first|second|third)"
BOOK_PIECE_RE = re.compile(
r"^\s*(?P<book>(?:(?:[1-3]|" + ROMAN_PREFIX + r"|" + ORDINAL_PREFIX + r")\.?\s*)?[a-z][a-z.\s]*?)"
r"\s*(?P<rest>\d[\d\s:.\-–—]*)?\s*$",
re.I,
)
CONTINUATION_RE = re.compile(r"^\s*\d[\d\s:.\-–—]*$")
def normalize_book_text(text: str) -> str:
"""'I Cor.' / '1st Corinthians' / '1 corinthians' -> '1corinthians'"""
text = text.lower().strip()
text = re.sub(r"^(the\s+)?(book\s+of\s+)", "", text)
text = re.sub(r"^(1st|first)(?=[\s.a-z]|$)", "1", text)
text = re.sub(r"^(2nd|second)(?=[\s.a-z]|$)", "2", text)
text = re.sub(r"^(3rd|third)(?=[\s.a-z]|$)", "3", text)
text = re.sub(r"^iii(?=[\s.]|$)", "3", text)
text = re.sub(r"^ii(?=[\s.]|$)", "2", text)
text = re.sub(r"^i(?=[\s.])", "1", text)
return re.sub(r"[^a-z0-9]", "", text)
def build_book_aliases():
"""alias key -> book id, plus the canonical keys on their own"""
canonical = {}
aliases = {}
for book_id, name in cached["book_id_to_name"].items():
canonical[normalize_book_text(name)] = book_id
aliases.update(canonical)
for abbreviation, book_id in kjv_cur.execute("SELECT a, b FROM key_abbreviations_english").fetchall():
aliases.setdefault(normalize_book_text(abbreviation), book_id)
for name, extras in EXTRA_BOOK_ALIASES.items():
book_id = lookup_book_id(name)
for extra in extras:
aliases[normalize_book_text(extra)] = book_id
return canonical, aliases
BOOK_CANONICAL, BOOK_ALIASES = build_book_aliases()
BOOK_ALIAS_KEYS = sorted(BOOK_ALIASES)
MAX_CHAPTER_ID_SPAN = 999
def resolve_book(token: str, loose: bool = True):
"""Returns (book_id, how) with how in exact|prefix|fuzzy, or None.
loose=False only accepts full names and spellings that are unmistakably
a book (used for a bare word with no chapter number).
"""
key = normalize_book_text(token)
if not key:
return None
if not loose:
if key in BOOK_CANONICAL or key in BARE_BOOK_EXTRA:
return (BOOK_ALIASES[key], "exact")
# a clear misspelling of a long full name ("genisis")
if len(key) >= 6 and not key[0].isdigit():
close = difflib.get_close_matches(key, list(BOOK_CANONICAL), n=1, cutoff=0.85)
if close:
return (BOOK_CANONICAL[close[0]], "fuzzy")
return None
if key in BOOK_ALIASES:
return (BOOK_ALIASES[key], "exact")
# unique (or best) prefix: "deut", "1cor", "matth"
prefixed = [k for k in BOOK_ALIAS_KEYS if k.startswith(key)]
if prefixed:
books = {BOOK_ALIASES[k] for k in prefixed}
canonical_hits = {BOOK_CANONICAL[k] for k in BOOK_CANONICAL if k.startswith(key)}
if len(canonical_hits) == 1:
return (next(iter(canonical_hits)), "prefix")
if len(books) == 1:
return (next(iter(books)), "prefix")
return (max(canonical_hits or books), "prefix") # ambiguous: the later book, as before
if len(key) >= 4:
close = difflib.get_close_matches(key, BOOK_ALIAS_KEYS, n=1, cutoff=0.78)
if close:
return (BOOK_ALIASES[close[0]], "fuzzy")
return None
def chapter_bounds(book_id: int, chapter: int) -> int:
"""Clamp a chapter number into the book (0 -> 1, past the end -> last chapter)."""
return max(1, min(chapter, cached["books_chapter_lengths"][book_id]))
def parse_ref_rest(rest: str, book_id: int, previous=None):
"""'3:16-18', '3 16', '3.16', '3-4', '3:16-4:2', '3', '3:-' -> (start_id, end_id|None, chapter, verse)"""
rest = rest.replace("–", "-").replace("—", "-")
rest = re.sub(r"\s*([:\-])\s*", r"\1", rest.strip())
base = book_id * 1_000_000
m = re.fullmatch(r"(\d+)", rest)
if m:
n = int(m.group(1))
if previous and previous["verse"]: # "John 3:16, 18": a verse of the same chapter
chapter = previous["chapter"]
return (base + chapter * 1000 + n, None, chapter, n)
chapter = chapter_bounds(book_id, n)
return (base + chapter * 1000 + 1, base + chapter * 1000 + MAX_CHAPTER_ID_SPAN, chapter, 0)
m = re.fullmatch(r"(\d+)(?::-|-)", rest) # "3:-" or "3-": the whole chapter
if m:
chapter = chapter_bounds(book_id, int(m.group(1)))
return (base + chapter * 1000 + 1, base + chapter * 1000 + MAX_CHAPTER_ID_SPAN, chapter, 0)
m = re.fullmatch(r"(\d+)-(\d+)", rest) # chapters 3 to 4
if m:
first, last = chapter_bounds(book_id, int(m.group(1))), chapter_bounds(book_id, int(m.group(2)))
if last < first:
first, last = last, first
return (base + first * 1000 + 1, base + last * 1000 + MAX_CHAPTER_ID_SPAN, first, 0)
m = re.fullmatch(r"(\d+)[:.\s](\d+)", rest)
if m:
chapter = chapter_bounds(book_id, int(m.group(1)))
verse = max(1, int(m.group(2)))
return (base + chapter * 1000 + verse, None, chapter, verse)
m = re.fullmatch(r"(\d+)[:.\s](\d+)-", rest) # "3:16-": from verse 16 to the end
if m:
chapter = chapter_bounds(book_id, int(m.group(1)))
return (base + chapter * 1000 + max(1, int(m.group(2))), base + chapter * 1000 + MAX_CHAPTER_ID_SPAN, chapter, int(m.group(2)))
m = re.fullmatch(r"(\d+)[:.\s](\d+)-(\d+)", rest)
if m:
chapter = chapter_bounds(book_id, int(m.group(1)))
first, last = sorted((max(1, int(m.group(2))), max(1, int(m.group(3)))))
return (base + chapter * 1000 + first, base + chapter * 1000 + last, chapter, first)
m = re.fullmatch(r"(\d+)[:.\s](\d+)-(\d+)[:.](\d+)", rest) # 3:16-4:2 across chapters
if m:
c1, c2 = chapter_bounds(book_id, int(m.group(1))), chapter_bounds(book_id, int(m.group(3)))
start = base + c1 * 1000 + max(1, int(m.group(2)))
end = base + c2 * 1000 + max(1, int(m.group(4)))
if end < start:
return None
return (start, end, c1, int(m.group(2)))
return None
def resolve_references(text: str, loose_bare: bool = False):
"""Parse a whole query ('John 3:16, Romans 5:8') into verse id ranges.
Returns None when it does not read as scripture references, so the
caller can treat it as a keyword search. Otherwise a dict with:
refs - list of (start_id, end_id or None)
notes - short explanations of guesses that were made
bare - the query was just a book name (shows its first chapter)
label - the name of the first book, for messages
"""
if not text or not text.strip():
return None
pieces = [p for p in re.split(r"[,;\n]+", text) if p.strip()]
if not pieces or len(pieces) > 20:
return None
refs, notes = [], []
bare = False
previous = None
label = None
for piece in pieces:
m = BOOK_PIECE_RE.match(piece)
if m:
token, rest = m.group("book"), m.group("rest")
if rest:
found = resolve_book(token)
else:
found = resolve_book(token, loose=loose_bare)
if not found:
return None
book_id, how = found
name = lookup_bookname(book_id)
if how == "fuzzy":
notes.append((token.strip(), name))
if not rest:
if len(pieces) == 1:
bare = True
start, end, chapter, verse = (book_id * 1_000_000 + 1001, book_id * 1_000_000 + 1999, 1, 0)
else:
parsed = parse_ref_rest(rest, book_id, None)
if not parsed:
return None
start, end, chapter, verse = parsed
elif CONTINUATION_RE.match(piece) and previous:
book_id = previous["book"]
name = lookup_bookname(book_id)
parsed = parse_ref_rest(piece, book_id, previous)
if not parsed:
return None
start, end, chapter, verse = parsed
else:
return None
previous = {"book": book_id, "chapter": chapter, "verse": verse}
label = label or name
refs.append((start, end))
return {"refs": refs, "notes": notes, "bare": bare, "label": label}
@app.get("/show") # user facing to show a group of scripture. This will be deprecated and moved to backend.
def show(kw=None, get_start_verse_id=False, human_readable=False):
ref: str = request.args.get("kw", None)
@@ -629,61 +1226,20 @@ def show(kw=None, get_start_verse_id=False, human_readable=False):
if kw:
ref = kw
if not ref:
return send_file("html/show.html")
return static_page("html/show.html")
logging.debug(f"show({ref})")
if ref.endswith(","):
ref = ref[:-1]
refs: list = []
for r in ref.split(","):
r_ = ref_input_cleaning(r.strip())
if r_:
refs.append(r_)
if not refs:
resolved = resolve_references(ref, loose_bare=True)
if not resolved:
return "Invalid Query"
if get_start_verse_id:
return str(resolved["refs"][0][0])
verse_results: list = []
# check if the search is for multiple refs
for ref in refs:
if len(ref) < 2:
continue
logging.debug(f"show.reference({ref})")
maybe_chapter: str = ref.split(":")[0][3:]
ref_chapter: str = ""
for maybedigit in maybe_chapter:
if maybedigit.isdigit():
ref_chapter += maybedigit
ref_verse = ref.split(":")[1]
if ref.__contains__("-") or get_chapter:
if ref.endswith("-") or get_chapter:
start_verse = ref_verse.split("-")[0]
end_verse = "500" # just get all the verses if it ends with a dash
else:
start_verse, end_verse = (
ref_verse.split("-")[0],
ref_verse.split("-")[1],
)
else:
start_verse = ref_verse
end_verse = None
end_verse_id = None
book_str: str = ref_input_cleaning(ref, string.ascii_letters)
ref_bookname: str = ref_parse_book(book_str)
ref_book_id: int = lookup_book_id(ref_bookname)
if not ref_book_id:
return "Invalid Query"
maximum_chapters: int = cached.get("books_chapter_lengths").get(ref_book_id)
if int(ref_chapter) > maximum_chapters:
ref_chapter = str(maximum_chapters)
start_verse_id = get_verse_id(ref_book_id, ref_chapter, start_verse)
if get_start_verse_id:
return start_verse_id
if end_verse:
end_verse_id = get_verse_id(ref_book_id, ref_chapter, end_verse)
logging.debug(f"show.start_verse_id({start_verse_id})")
logging.debug(f"show.end_verse_id({end_verse_id})")
results = lookup_by_verse_id(start_verse_id, end_verse_id)
for start_id, end_id in resolved["refs"]:
if get_chapter: # ?all= : from the given verse to the end of its chapter
end_id = start_id // 1000 * 1000 + MAX_CHAPTER_ID_SPAN
results = lookup_by_verse_id(start_id, end_id)
logging.debug(f"show.results len({len(results)})")
for result in results:
verse_results.append(result)
verse_results.extend(results)
response_list = scripture_response(verse_results, human_readable)
arg_view = request.args.get("view", None)
if arg_view == "plain":
@@ -722,7 +1278,7 @@ def response_rich(scripture):
text = str(scripture[4])
passage = bookname + " " + chapter + ":" + verse
return cached["html_response"].format(
chapter=passage.split(":")[0], passage=passage, text=text
chapter=passage.split(":")[0], passage=passage, text=text, verse_id=scripture[0]
)
@@ -739,22 +1295,18 @@ def scripture_response(scriptures, human_readable=False):
except IndexError:
logging.error(f"Malformed query: {request.args.get('kw',None)}")
return(("Malformed Query - if you believe this to be in error (your query should resolve), email tyler@dinsmoor.us",))
l_r_nav = get_next_prev_chapter(
str(scriptures[0][0])
) # get the first scripture, the id thereof
# Prev/next chapter links only make sense when reading a single chapter,
# not for a keyword search that spans books.
l_r_nav = None
if len({str(scripture[0])[:-3] for scripture in scriptures}) == 1:
l_r_nav = get_next_prev_chapter(
str(scriptures[0][0])
) # get the first scripture, the id thereof
# response.append(cached["css_response"])
response.append(
cached["html_navbar"].format(
query=request.args.get("kw", ""),
current_chapter_name=l_r_nav["current_chapter_name"],
next_chapter_name=l_r_nav["next_chapter_name"],
next_chapter_link=l_r_nav["next_chapter_link"],
prev_chapter_name=l_r_nav["prev_chapter_name"],
prev_chapter_link=l_r_nav["prev_chapter_link"],
)
render_navbar(request.args.get("kw", ""), l_r_nav)
)
response.append(cached["css_navbar"])
response.append("<div class='responses'>")
response.append(f"<div class='results-count'>{len(scriptures)} Results</div>")
for scripture in scriptures:
@@ -787,11 +1339,11 @@ def idx(shortcode=""):
@app.get("/fe")
def frontend():
return send_file('kjv_fe.html')
return static_page("kjv_fe.html")
@app.get("/salvation") # a web page about going to heaven
def salvation():
return send_file("html/heaven.html")
return static_page("html/heaven.html")
@app.get("/t/random", defaults={"view": "plain"}) # backwards-compatibility plaintext random Bible verse
@@ -851,9 +1403,9 @@ def search():
if keywords:
cleaned_keywords = search_input_cleaning(keywords)
if not cleaned_keywords:
return send_file("html/search.html")
return static_page("html/search.html")
else:
return send_file("html/search.html")
return static_page("html/search.html")
# Check if this is a common theological concept not in KJV
concept_mapping = cached["concept_mappings"].get(cleaned_keywords.lower(), None)
@@ -1054,15 +1606,7 @@ def search():
if arg_view == "rich":
# Build a nice no-results page with header
no_results_html = cached["html_navbar"].format(
query=cleaned_keywords,
current_chapter_name="",
next_chapter_name="",
next_chapter_link="",
prev_chapter_name="",
prev_chapter_link=""
)
no_results_html += cached["css_navbar"]
no_results_html = render_navbar(cleaned_keywords)
no_results_html += "<div class='responses'>"
no_results_html += f"<div class='correction-notice'><strong>No results found</strong> for '{cleaned_keywords}'.</div>"
@@ -1143,7 +1687,7 @@ def search():
@app.get("/browse") # not implemented. TO be moved to frontend.
def bible_reader():
return render_template("reader.html", books=cached.get("book_names"))
return render_template("reader.html", books=cached.get("book_names"), v=ASSET_VERSION)
# We should return an index of all books with
# drop down menus of each chapter, and a search
# bar at the top for quick seeking, which can use
@@ -1153,23 +1697,17 @@ def bible_reader():
# Maybe preliminary theming support
@app.get("/plan") # not implmeneted. To be moved to frontend.
def reading_plan():
# needs 2 sets of data, books and time frame.
# should default to the entire Bible in 1 year.
# should start today() and end today()+1year
# Should return reading_plan template.
return render_template("reading_plan.html")
@app.get("/status") # basic instance info
def status():
import resource
return f"""Sanity: {sanity} <br>
UserMode: {usermode} <br>
Memory: {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss}
"""
return static_html_doc(
"Status",
f"""<h2>Status</h2>
<p>Sanity: {sanity}</p>
<p>UserMode: {usermode}</p>
<p>Memory: {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss}</p>""",
)
@app.get("/seqreport") # shows current tracked scripture sequences
@@ -1180,7 +1718,7 @@ def sequential_report(uid=None):
seq_conn = sqlite3.connect("data/seq.db")
seq_cur = seq_conn.cursor()
uids = seq_cur.execute("select * from reading_tracker").fetchall()
return render_template("seq_report.html", uids=uids)
return render_template("seq_report.html", uids=uids, v=ASSET_VERSION)
@app.get("/screport") # shows current remembered shortcodes
def shortcode_report(shortcode=None):
@@ -1192,7 +1730,7 @@ def shortcode_report(shortcode=None):
shortcodes = short_cur.execute(
"SELECT * FROM shortcodes"
).fetchall()
return render_template("shortcode_report.html", shortcodes=shortcodes)
return render_template("shortcode_report.html", shortcodes=shortcodes, v=ASSET_VERSION)
def sequential_purge():
@@ -1226,7 +1764,7 @@ def sequential_read(start=0, num=1, uid=None, getuid=False):
except ValueError:
return "Invalid num argument"
if not uid and not getuid:
return send_file("html/seq.html")
return static_page("html/seq.html")
if usermode == "Single":
seq_conn = sqlite3.connect("data/seq.db", check_same_thread=False)
else: