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
+652 -114
View File
@@ -19,6 +19,12 @@ import sys
import logging import logging
import socket import socket
import json import json
import hashlib
import re
import difflib
import math
import urllib.parse
import html as html_lib
from tools import sanity_check from tools import sanity_check
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -75,6 +81,277 @@ def dict_factory(cursor, row):
kjv_cur = None kjv_cur = None
sermon_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__": if __name__ == "__main__":
# single user mode, running without uwsgi # single user mode, running without uwsgi
usermode: str = "Single" 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 = json.load(open("data/concept_mappings.json", "r"))
concept_mappings_dict = {mapping["query"].lower(): mapping for mapping in concept_mappings} 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 = { cached: dict = {
"verse_ids": [i[0] for i in kjv_cur.execute("SELECT id FROM fts_kjv;").fetchall()], "verse_ids": [i[0] for i in kjv_cur.execute("SELECT id FROM fts_kjv;").fetchall()],
"book_names": [ "book_names": [
@@ -129,19 +420,29 @@ cached: dict = {
"theological_synonyms": theological_synonyms_map, "theological_synonyms": theological_synonyms_map,
"concept_mappings": concept_mappings_dict, "concept_mappings": concept_mappings_dict,
"html_response": open("html/response.html", "r").read(), "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_navbar": open("styles/header.css", "r").read(),
"css_response": open("styles/human_readable.css", "r").read(), "css_response": open("styles/human_readable.css", "r").read(),
"css_collapsible": open("styles/collapsible.css", "r").read(), "css_collapsible": open("styles/collapsible.css", "r").read(),
"css_page": open("styles/page.css", "r").read(), "css_page": open("styles/page.css", "r").read(),
"js_collapsible": open("js/collapsible.js", "r").read(), "js_collapsible": open("js/collapsible.js", "r").read(),
"js_shortcode": open("js/shortcode.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(), "favicon_svg": open("static/favicon.svg", "r").read(),
"book_info": kjv_cur.execute( "book_info": kjv_cur.execute(
'SELECT "order", title_short, category, otnt, chapters FROM book_info ORDER BY "order"' 'SELECT "order", title_short, category, otnt, chapters FROM book_info ORDER BY "order"'
).fetchall(), ).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. # Fill the chapter list with unique chapters for seeking back and forward by index.
[ [
cached["chapter_list"].append(int(str(chap)[:-3])) 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 book_order, title, category, testament, num_chapters = book
testament_data[testament][category].append({ testament_data[testament][category].append({
"title": title, "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) cached["book_info_for_index"] = dict(testament_data)
# Render the index page once and cache it # Render the index page once and cache it
with app.app_context(): with app.app_context():
from flask import render_template_string 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"]) cached["html_index"] = render_template_string(template_content, testament_data=cached["book_info_for_index"])
@app.get("/js/shortcode.js") @app.get("/js/shortcode.js")
@@ -182,6 +485,18 @@ def serve_shortcode_js():
response.headers['Cache-Control'] = 'public, max-age=31536000' response.headers['Cache-Control'] = 'public, max-age=31536000'
return response 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") @app.get("/js/collapsible.js")
def serve_collapsible_js(): def serve_collapsible_js():
response = Response(cached.get("js_collapsible"), mimetype='application/javascript') response = Response(cached.get("js_collapsible"), mimetype='application/javascript')
@@ -201,6 +516,49 @@ def serve_favicon():
response.headers['Cache-Control'] = 'public, max-age=31536000' response.headers['Cache-Control'] = 'public, max-age=31536000'
return response 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(): def generate_short_id():
remain = random.randrange(614_656, 17_210_367) # resolves to length of 5 remain = random.randrange(614_656, 17_210_367) # resolves to length of 5
chars = "0123456789ACEFHJKLMNPRTUVWXY" chars = "0123456789ACEFHJKLMNPRTUVWXY"
@@ -511,26 +869,35 @@ def parse_query(query_override: str = None):
return jsonify({"Error": "/find endpoint only supports rich responses"}) return jsonify({"Error": "/find endpoint only supports rich responses"})
logging.info(f"Query: {query}") logging.info(f"Query: {query}")
if not query: if not query:
return send_file("html/find.html") return static_page("html/find.html")
if query.endswith(","): if query.endswith(","):
query = query[:-1] query = query[:-1]
# Check if query contains both letters and digits (required for reference parsing) # A query that reads as scripture references ("Exodus", "1 jn 3.16") is shown
# If only numbers or only letters, skip reference parsing and go to keyword search # as scripture; anything else is a keyword search.
has_letters = any(char.isalpha() for char in query) resolved = resolve_references(query)
has_digits = any(char.isdigit() for char in query) if resolved:
if not (has_letters and has_digits):
result = search()
else:
result = show(kw=query, human_readable=human_readable) result = show(kw=query, human_readable=human_readable)
if not hasattr(result, "__len__"): if isinstance(result, str) and result != "Invalid Query":
logging.debug(f"parse_query.show = '{result}'") 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 return result
if not search_input_cleaning(query).strip():
if len(result) > 17: # nothing searchable left (punctuation, accents): show the search form
return result return static_page("html/find.html")
# Reference parsing failed, fall back to keyword search
result = search() result = search()
# Remove the closing </div> from search() so we can add dictionary content inside .responses # Remove the closing </div> from search() so we can add dictionary content inside .responses
if result.endswith("</div>"): if result.endswith("</div>"):
@@ -561,7 +928,7 @@ def parse_query(query_override: str = None):
result += "<style>" + cached["css_collapsible"] + "</style>" 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 += "<div class='dictionary-section'><h3>1828 Webster Definitions</h3><p>Click on the word to get the definition.</p></div>"
result += definitions_html result += definitions_html
result += "<script src='/js/collapsible.js'></script>" result += f"<script src='/js/collapsible.js?v={ASSET_VERSION}'></script>"
result += "</div>" result += "</div>"
return result return result
@@ -575,7 +942,7 @@ def define(word=None):
if ref: if ref:
ref = ref.strip() ref = ref.strip()
if not ref: if not ref:
return send_file("html/dictionary.html") return static_page("html/dictionary.html")
dictionary = cached["json_dictionary"] dictionary = cached["json_dictionary"]
ref = ref.lower() ref = ref.lower()
if not ref in dictionary.keys(): if not ref in dictionary.keys():
@@ -609,19 +976,249 @@ def return_dict_plain(ref, defs):
def return_dict_rich(ref, defs, simple): def return_dict_rich(ref, defs, simple):
if simple: if simple:
css = "" css = ""
head = ""
else: 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 = "" response = ""
for e in defs: for e in defs:
response += e + "<br><br>" response += e + "<br><br>"
return ( return (
cached["html_response"].format( head
chapter=ref.split(":")[0], passage=ref.capitalize(), text=response + cached["html_response"].format(
chapter=ref.split(":")[0], passage=ref.capitalize(), text=response, verse_id=""
) )
+ css + 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. @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): def show(kw=None, get_start_verse_id=False, human_readable=False):
ref: str = request.args.get("kw", None) 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: if kw:
ref = kw ref = kw
if not ref: if not ref:
return send_file("html/show.html") return static_page("html/show.html")
logging.debug(f"show({ref})") logging.debug(f"show({ref})")
if ref.endswith(","): resolved = resolve_references(ref, loose_bare=True)
ref = ref[:-1] if not resolved:
refs: list = []
for r in ref.split(","):
r_ = ref_input_cleaning(r.strip())
if r_:
refs.append(r_)
if not refs:
return "Invalid Query" return "Invalid Query"
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: if get_start_verse_id:
return start_verse_id return str(resolved["refs"][0][0])
if end_verse: verse_results: list = []
end_verse_id = get_verse_id(ref_book_id, ref_chapter, end_verse) for start_id, end_id in resolved["refs"]:
logging.debug(f"show.start_verse_id({start_verse_id})") if get_chapter: # ?all= : from the given verse to the end of its chapter
logging.debug(f"show.end_verse_id({end_verse_id})") end_id = start_id // 1000 * 1000 + MAX_CHAPTER_ID_SPAN
results = lookup_by_verse_id(start_verse_id, end_verse_id) results = lookup_by_verse_id(start_id, end_id)
logging.debug(f"show.results len({len(results)})") logging.debug(f"show.results len({len(results)})")
for result in results: verse_results.extend(results)
verse_results.append(result)
response_list = scripture_response(verse_results, human_readable) response_list = scripture_response(verse_results, human_readable)
arg_view = request.args.get("view", None) arg_view = request.args.get("view", None)
if arg_view == "plain": if arg_view == "plain":
@@ -722,7 +1278,7 @@ def response_rich(scripture):
text = str(scripture[4]) text = str(scripture[4])
passage = bookname + " " + chapter + ":" + verse passage = bookname + " " + chapter + ":" + verse
return cached["html_response"].format( 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: except IndexError:
logging.error(f"Malformed query: {request.args.get('kw',None)}") 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",)) return(("Malformed Query - if you believe this to be in error (your query should resolve), email tyler@dinsmoor.us",))
# 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( l_r_nav = get_next_prev_chapter(
str(scriptures[0][0]) str(scriptures[0][0])
) # get the first scripture, the id thereof ) # get the first scripture, the id thereof
# response.append(cached["css_response"]) # response.append(cached["css_response"])
response.append( response.append(
cached["html_navbar"].format( render_navbar(request.args.get("kw", ""), l_r_nav)
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"],
)
) )
response.append(cached["css_navbar"])
response.append("<div class='responses'>") response.append("<div class='responses'>")
response.append(f"<div class='results-count'>{len(scriptures)} Results</div>") response.append(f"<div class='results-count'>{len(scriptures)} Results</div>")
for scripture in scriptures: for scripture in scriptures:
@@ -787,11 +1339,11 @@ def idx(shortcode=""):
@app.get("/fe") @app.get("/fe")
def frontend(): def frontend():
return send_file('kjv_fe.html') return static_page("kjv_fe.html")
@app.get("/salvation") # a web page about going to heaven @app.get("/salvation") # a web page about going to heaven
def salvation(): 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 @app.get("/t/random", defaults={"view": "plain"}) # backwards-compatibility plaintext random Bible verse
@@ -851,9 +1403,9 @@ def search():
if keywords: if keywords:
cleaned_keywords = search_input_cleaning(keywords) cleaned_keywords = search_input_cleaning(keywords)
if not cleaned_keywords: if not cleaned_keywords:
return send_file("html/search.html") return static_page("html/search.html")
else: else:
return send_file("html/search.html") return static_page("html/search.html")
# Check if this is a common theological concept not in KJV # Check if this is a common theological concept not in KJV
concept_mapping = cached["concept_mappings"].get(cleaned_keywords.lower(), None) concept_mapping = cached["concept_mappings"].get(cleaned_keywords.lower(), None)
@@ -1054,15 +1606,7 @@ def search():
if arg_view == "rich": if arg_view == "rich":
# Build a nice no-results page with header # Build a nice no-results page with header
no_results_html = cached["html_navbar"].format( no_results_html = render_navbar(cleaned_keywords)
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 += "<div class='responses'>" no_results_html += "<div class='responses'>"
no_results_html += f"<div class='correction-notice'><strong>No results found</strong> for '{cleaned_keywords}'.</div>" 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. @app.get("/browse") # not implemented. TO be moved to frontend.
def bible_reader(): 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 # We should return an index of all books with
# drop down menus of each chapter, and a search # drop down menus of each chapter, and a search
# bar at the top for quick seeking, which can use # bar at the top for quick seeking, which can use
@@ -1153,23 +1697,17 @@ def bible_reader():
# Maybe preliminary theming support # 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 @app.get("/status") # basic instance info
def status(): def status():
import resource import resource
return f"""Sanity: {sanity} <br> return static_html_doc(
UserMode: {usermode} <br> "Status",
Memory: {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss} 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 @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_conn = sqlite3.connect("data/seq.db")
seq_cur = seq_conn.cursor() seq_cur = seq_conn.cursor()
uids = seq_cur.execute("select * from reading_tracker").fetchall() 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 @app.get("/screport") # shows current remembered shortcodes
def shortcode_report(shortcode=None): def shortcode_report(shortcode=None):
@@ -1192,7 +1730,7 @@ def shortcode_report(shortcode=None):
shortcodes = short_cur.execute( shortcodes = short_cur.execute(
"SELECT * FROM shortcodes" "SELECT * FROM shortcodes"
).fetchall() ).fetchall()
return render_template("shortcode_report.html", shortcodes=shortcodes) return render_template("shortcode_report.html", shortcodes=shortcodes, v=ASSET_VERSION)
def sequential_purge(): def sequential_purge():
@@ -1226,7 +1764,7 @@ def sequential_read(start=0, num=1, uid=None, getuid=False):
except ValueError: except ValueError:
return "Invalid num argument" return "Invalid num argument"
if not uid and not getuid: if not uid and not getuid:
return send_file("html/seq.html") return static_page("html/seq.html")
if usermode == "Single": if usermode == "Single":
seq_conn = sqlite3.connect("data/seq.db", check_same_thread=False) seq_conn = sqlite3.connect("data/seq.db", check_same_thread=False)
else: else:
+68
View File
@@ -0,0 +1,68 @@
{
"Genesis": "Creation, the fall, the flood, and the patriarchs Abraham, Isaac, Jacob and Joseph.",
"Exodus": "Israel's deliverance from Egypt, the Law at Sinai, and the building of the tabernacle.",
"Leviticus": "God's laws for offerings, priests, holiness and the feasts of Israel.",
"Numbers": "Israel's census and forty years of wandering in the wilderness.",
"Deuteronomy": "Moses' final sermons, restating the Law before Israel enters the promised land.",
"Joshua": "Israel crosses the Jordan, conquers Canaan and divides the land.",
"Judges": "A cycle of sin and rescue as God raises up judges over Israel.",
"Ruth": "A Moabite widow's faithfulness and her redeemer Boaz, an ancestor of David.",
"1 Samuel": "Samuel, the rise and failure of King Saul, and David's anointing.",
"2 Samuel": "David's reign over Israel, his sin with Bathsheba, and the troubles that followed.",
"1 Kings": "Solomon's glory and temple, the divided kingdom, and the prophet Elijah.",
"2 Kings": "The decline of Israel and Judah to exile in Assyria and Babylon.",
"1 Chronicles": "Genealogies from Adam and the reign of David, with a focus on worship.",
"2 Chronicles": "Solomon's temple and the kings of Judah, ending with the exile.",
"Ezra": "The return from Babylon and the rebuilding of the temple.",
"Nehemiah": "The rebuilding of Jerusalem's walls and the people's renewed covenant.",
"Esther": "A Jewish queen in Persia saves her people from Haman's plot.",
"Job": "A righteous man suffers loss and questions, and God answers him.",
"Psalms": "Songs and prayers of praise, lament, trust and thanksgiving.",
"Proverbs": "Short sayings of wisdom for godly living.",
"Ecclesiastes": "The Preacher finds life under the sun empty without fearing God.",
"Song of Solomon": "A poem of love between a bridegroom and his bride.",
"Isaiah": "Prophecies of judgment and comfort, and of the coming Messiah and His kingdom.",
"Jeremiah": "The weeping prophet warns Judah of Babylon's coming and promises a new covenant.",
"Lamentations": "Poems of grief over the fall of Jerusalem.",
"Ezekiel": "Visions of judgment on Jerusalem, and of Israel's restoration and a new temple.",
"Daniel": "Faithful Daniel in Babylon, the lions' den, and visions of coming kingdoms.",
"Hosea": "God's faithful love for unfaithful Israel, pictured in the prophet's marriage.",
"Joel": "A plague of locusts calls Judah to repent and promises the Spirit poured out.",
"Amos": "A herdsman prophet condemns injustice and warns Israel of judgment.",
"Obadiah": "Judgment on Edom for its pride and its violence against Judah.",
"Jonah": "A reluctant prophet flees God, is swallowed by a fish, and preaches to Nineveh.",
"Micah": "Judgment on Israel's rulers, and the promise of a ruler born in Bethlehem.",
"Nahum": "The coming destruction of Nineveh.",
"Habakkuk": "The prophet questions God about evil and learns that the just shall live by faith.",
"Zephaniah": "The coming day of the LORD and a promise to restore a remnant.",
"Haggai": "A call to the returned exiles to rebuild the temple.",
"Zechariah": "Visions and prophecies encouraging the rebuilding, and of the coming King.",
"Malachi": "A last warning to a careless people, and the promise of a messenger before the LORD.",
"Matthew": "The gospel of Jesus Christ as the promised King of the Jews.",
"Mark": "A fast-moving gospel of Jesus as the servant who died and rose again.",
"Luke": "A careful account of the life of Jesus, the Son of man who came to seek the lost.",
"John": "The gospel of Jesus as the Son of God, written that you might believe and have life.",
"Acts": "The apostles spread the gospel from Jerusalem to Rome after Christ's ascension.",
"Romans": "Salvation by grace through faith, for Jew and Gentile alike.",
"1 Corinthians": "Paul corrects divisions and disorder in the church, and teaches on love and the resurrection.",
"2 Corinthians": "Paul defends his ministry and encourages comfort and generosity.",
"Galatians": "Freedom in Christ: we are justified by faith, not by the works of the law.",
"Ephesians": "The church as Christ's body, saved by grace and called to walk worthy.",
"Philippians": "A joyful letter on Christ's humility and rejoicing in all circumstances.",
"Colossians": "Christ is supreme over all, and complete in Him.",
"1 Thessalonians": "Encouragement to the young church, and the hope of Christ's return.",
"2 Thessalonians": "Correction about the day of the Lord, and a call to steadfast work.",
"1 Timothy": "Paul's instructions to a young pastor on church order and sound doctrine.",
"2 Timothy": "Paul's last letter, urging Timothy to be faithful and preach the word.",
"Titus": "Qualifications for elders and good works in the churches of Crete.",
"Philemon": "Paul appeals to Philemon to receive back his runaway servant Onesimus as a brother.",
"Hebrews": "Christ is better than the angels, Moses and the priests, and faith endures.",
"James": "Practical faith: be doers of the word, control the tongue, and pray.",
"1 Peter": "Hope and holy living for believers who suffer for Christ.",
"2 Peter": "Grow in grace and knowledge, and beware of false teachers and scoffers.",
"1 John": "Fellowship with God: walk in light, love one another, and know you have eternal life.",
"2 John": "A short warning to walk in truth and love and to avoid deceivers.",
"3 John": "Praise for hospitality to travelling believers, and a rebuke of Diotrephes.",
"Jude": "A call to contend for the faith against ungodly men.",
"Revelation": "Visions of Christ, the churches, coming judgments, and the new heaven and earth."
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ info:
version: 1.0.0 version: 1.0.0
servers: servers:
- url: https://api.1611.social - url: https://bible.nicecrew.digital
# Add security definitions for authorization tokens # Add security definitions for authorization tokens
security: security:
+5
View File
@@ -0,0 +1,5 @@
<nav class="chapter-nav" aria-label="Chapter">
<a class="chapter-prev" href="{prev_chapter_link}" rel="prev" aria-label="Previous chapter: {prev_chapter_name}"><span aria-hidden="true">&lsaquo;</span><span class="nav-name">{prev_chapter_name}</span></a>
<a class="chapter-current" href="{current_chapter_link}" title="Read the whole chapter">{current_chapter_name}</a>
<a class="chapter-next" href="{next_chapter_link}" rel="next" aria-label="Next chapter: {next_chapter_name}"><span class="nav-name">{next_chapter_name}</span><span aria-hidden="true">&rsaquo;</span></a>
</nav>
+10 -10
View File
@@ -4,22 +4,22 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bible Dictionary - KJV API</title> <title>Bible Dictionary - KJV API</title>
<link rel="stylesheet" href="/styles/page.css"> <link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>body{background-color:#1a1a1a}</style>
</head> </head>
<body> <body>
<header> <header>
<a href="/">Home</a> | <a href="/define">Dictionary</a> | <a href="/">Home</a> <a href="/define">Dictionary</a>
<a href="/random?view=rich">Random Verse</a> | <a href="/votd?view=rich">Verse of the Day</a> <a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header> </header>
<h2>Bible Dictionary</h2> <h2>Bible Dictionary</h2>
<form action="/define"> <form action="/define">
<label for='kw'>Word lookup</label> <label for="kw">Word lookup</label>
<input type="text" name="kw" placeholder="Enter a word to define..."> <input type="text" name="kw" id="kw" autocapitalize="off" autocomplete="off" enterkeyhint="search" placeholder="Enter a word to define...">
<label for='view'>View</label> <div class="form-row"><b>View</b><br>
<input type="radio" name="view" value="rich" checked="checked"> Rich <label class="choice"><input type="radio" name="view" value="rich" checked="checked"> Rich</label>
<input type="radio" name="view" value="plain"> Plain <label class="choice"><input type="radio" name="view" value="plain"> Plain</label>
<input type="radio" name="view" value="json"> JSON <label class="choice"><input type="radio" name="view" value="json"> JSON</label>
</div>
<input type="submit" value="Define"> <input type="submit" value="Define">
</form> </form>
<p>This tool uses an abridged 1828 Noah Webster dictionary.</p> <p>This tool uses an abridged 1828 Noah Webster dictionary.</p>
+6 -6
View File
@@ -4,19 +4,19 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quick Search - KJV API</title> <title>Quick Search - KJV API</title>
<link rel="stylesheet" href="/styles/page.css"> <link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>body{background-color:#1a1a1a}</style> <script src="/js/book_suggest.js?v=__V__" defer></script>
</head> </head>
<body> <body>
<header> <header>
<a href="/">Home</a> | <a href="/find">Quick Search</a> | <a href="/">Home</a> <a href="/find">Quick Search</a>
<a href="/random?view=rich">Random Verse</a> | <a href="/random?view=rich">Random Verse</a>
<a href="/votd?view=rich">Verse of the Day</a> <a href="/votd?view=rich">Verse of the Day</a>
</header> </header>
<h2>Quick Search</h2> <h2>Quick Search</h2>
<form action="/find"> <form action="/find">
<label for='kw'>Query</label> <label for="kw">Query</label>
<input type="text" name="kw" placeholder="Enter verse reference or keywords..."> <input type="text" name="kw" id="kw" data-book-suggest autocapitalize="off" autocomplete="off" enterkeyhint="search" placeholder="Enter verse reference or keywords...">
<input type="hidden" name="view" value="rich"> <input type="hidden" name="view" value="rich">
<input type="submit" value="Search"> <input type="submit" value="Search">
</form> </form>
+16
View File
@@ -1,3 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>How to Be Saved - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v=__V__">
</head>
<body>
<header>
<a href="/">Home</a> <a href="/find">Quick Search</a>
<a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header>
<h2>How to Be Saved</h2>
<h3>1. Understand you are a sinner:</h3> <h3>1. Understand you are a sinner:</h3>
<p>No one is good enough to go to Heaven on their own merit. No matter how much good we do, we still come short.</p> <p>No one is good enough to go to Heaven on their own merit. No matter how much good we do, we still come short.</p>
<p><b>Romans 3:23</b>(a) For all have sinned, and come short of the glory of God;</p> <p><b>Romans 3:23</b>(a) For all have sinned, and come short of the glory of God;</p>
@@ -28,3 +42,5 @@
<p>Let us help you word a prayer. Realize it is not mere words that save, but your faith in Jesus Christ.</p> <p>Let us help you word a prayer. Realize it is not mere words that save, but your faith in Jesus Christ.</p>
<p><i>Dear Jesus, I know that I am a sinner, and I know I deserve to go to Hell. But I believe that you died on the cross, was buried, and rose from the grave to pay for my sins. Please save me and take me to Heaven when I die. Amen.</i></p> <p><i>Dear Jesus, I know that I am a sinner, and I know I deserve to go to Hell. But I believe that you died on the cross, was buried, and rose from the grave to pay for my sins. Please save me and take me to Heaven when I die. Amen.</i></p>
</body>
</html>
+210 -109
View File
@@ -5,114 +5,77 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KJV API - Legacy Edition</title> <title>KJV API - Legacy Edition</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/styles/page.css"> <link rel="stylesheet" href="/styles/page.css?v=__V__">
<script src="/js/collapsible.js" defer></script> <script src="/js/collapsible.js?v=__V__" defer></script>
<style> <style>
/* Bible Book Styles */ /* Home page layout */
.book-grid { .site-title {
display: grid; margin: var(--space-md) 0 0.25rem;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
padding: 0.5rem 0;
} }
.book-button { .tagline {
background-color: var(--color-bg-tertiary); margin: 0 0 var(--space-lg);
border: 1px solid var(--color-border); color: var(--color-text-muted);
border-radius: var(--border-radius-sm);
padding: 0.75rem 1rem;
text-align: left;
color: var(--color-text-primary);
transition: all var(--transition-fast);
cursor: pointer;
font-family: var(--font-ui);
font-weight: 600;
font-size: 0.95rem;
width: 100%;
}
.book-button:hover {
background-color: var(--color-accent-primary);
color: var(--color-bg-primary);
border-color: var(--color-accent-primary);
transform: translateY(-1px);
}
.book-button.active {
background-color: var(--color-accent-primary);
color: var(--color-bg-primary);
border-color: var(--color-accent-primary);
}
.chapter-grid {
display: none;
grid-template-columns: repeat(auto-fill, minmax(45px, 1fr));
gap: 0.4rem;
padding: 1rem;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.chapter-link {
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: 0.5rem;
text-align: center;
color: var(--color-text-primary);
font-size: 0.85rem;
transition: all var(--transition-fast);
display: block;
}
.chapter-link:hover {
background-color: var(--color-accent-primary);
color: var(--color-bg-primary);
text-decoration: none;
transform: translateY(-1px);
}
.quick-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
margin: 1.5rem 0;
}
.quick-btn {
background-color: var(--color-accent-primary);
color: var(--color-bg-primary);
padding: 0.75rem 1.5rem;
border-radius: var(--border-radius-sm);
font-weight: 600;
transition: all var(--transition-fast);
display: inline-block;
}
.quick-btn:hover {
background-color: var(--color-accent-hover);
transform: translateY(-1px);
text-decoration: none;
} }
.search-section { .search-section {
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
padding: var(--space-xl); padding: var(--space-lg);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
margin-bottom: var(--space-xl); margin-bottom: var(--space-xl);
box-shadow: 0 2px 8px var(--color-shadow); box-shadow: 0 2px 8px var(--color-shadow);
} }
.search-section form {
background: none;
border: none;
padding: 0;
margin: 0;
}
.search-row {
display: flex;
gap: var(--space-sm);
}
.search-row input[type=text] {
margin: 0;
flex: 1;
min-width: 0;
}
.quick-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.quick-btn {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
padding: 0.7rem 1rem;
min-height: 44px;
border-radius: var(--border-radius-sm);
font-weight: 600;
text-align: center;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.quick-btn:hover {
background-color: var(--color-accent-primary);
border-color: var(--color-accent-primary);
color: var(--color-on-accent);
text-decoration: none;
}
.testament-header { .testament-header {
color: var(--color-accent-primary); color: var(--color-accent-primary);
font-size: 1.8rem; font-size: 1.6rem;
margin-top: 2rem; margin-top: var(--space-2xl);
margin-bottom: 1rem; margin-bottom: var(--space-sm);
font-weight: 700; font-weight: 700;
border-bottom: 2px solid var(--color-accent-primary); border-bottom: 2px solid var(--color-accent-primary);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -120,43 +83,169 @@
.category-header { .category-header {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 1.1rem; font-size: 1.05rem;
margin-top: 1.5rem; margin-top: var(--space-lg);
margin-bottom: 0.75rem; margin-bottom: var(--space-sm);
font-weight: 600; font-weight: 600;
} }
@media (max-width: 768px) { /* Books: each book is a button; its chapter grid opens full-width underneath
the row it sits in (display: contents lets the chapter grid span the
book grid, and "dense" back-fills the gap with the following books). */
.book-grid { .book-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-flow: row dense;
gap: var(--space-sm);
} }
.quick-actions { .book-grid > div {
display: contents;
}
.book-button {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: 0.7rem 0.9rem;
min-height: 44px;
text-align: left;
color: var(--color-text-primary);
cursor: pointer;
font-family: var(--font-ui);
font-weight: 600;
font-size: 0.95rem;
width: 100%;
margin: 0;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.book-button:hover,
.book-button.active {
background-color: var(--color-accent-primary);
color: var(--color-on-accent);
border-color: var(--color-accent-primary);
transform: none;
}
.chapter-grid {
display: none;
grid-column: 1 / -1;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.4rem;
padding: var(--space-md);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-accent-primary);
border-radius: var(--border-radius-sm);
}
.book-about {
grid-column: 1 / -1;
margin: 0 0 var(--space-xs);
font-size: 0.95rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.chapter-link {
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-height: 44px;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--color-text-primary);
font-size: 0.95rem;
line-height: 1.35;
transition: background-color var(--transition-fast);
}
.chapter-num {
flex: 0 0 2.4rem;
text-align: center;
font-weight: 700;
color: var(--color-accent-primary);
}
.chapter-sum {
color: var(--color-text-secondary);
}
.chapter-link:hover {
background-color: var(--color-accent-primary);
color: var(--color-on-accent);
text-decoration: none;
}
.chapter-link:hover .chapter-num,
.chapter-link:hover .chapter-sum {
color: var(--color-on-accent);
}
.api-docs {
margin-top: var(--space-2xl);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-secondary);
}
.api-docs summary {
padding: 0.9rem 1.1rem;
font-weight: 600;
cursor: pointer;
}
.api-docs pre {
margin: 0;
border: none;
border-top: 1px solid var(--color-border);
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
}
@media (max-width: 480px) {
.book-grid {
grid-template-columns: repeat(2, 1fr);
}
.chapter-grid {
grid-template-columns: 1fr;
}
.search-row {
flex-direction: column; flex-direction: column;
} }
.quick-btn { .search-row input[type=submit] {
width: 100%; width: 100%;
} }
} }
</style> </style>
<script src="/js/book_suggest.js?v=__V__" defer></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>KJV API (Legacy edition)</h1> <h1 class="site-title">KJV API <small>(Legacy edition)</small></h1>
<p class="tagline">King James Bible: search, read, and query by reference.</p>
<div class="search-section"> <div class="search-section">
<form action="/find"> <form action="/find" role="search">
<label for='kw'>Search Scripture</label><br> <label for="kw">Search Scripture</label>
<input type="text" name="kw" id="kw" placeholder="Enter verse reference or search keywords..."><br><br> <div class="search-row">
<input type="text" name="kw" id="kw" data-book-suggest autocapitalize="off" autocomplete="off" enterkeyhint="search" placeholder="Verse reference or keywords...">
<input type="hidden" name="view" value="rich"> <input type="hidden" name="view" value="rich">
<input type="submit" value="Search"> <input type="submit" value="Search">
</div>
</form> </form>
<div class="quick-actions"> <div class="quick-actions">
<a href="/random?view=rich" class="quick-btn">Random Verse</a> <a href="/random?view=rich" class="quick-btn">Random Verse</a>
<a href="/votd?view=rich" class="quick-btn">Verse of the Day</a> <a href="/votd?view=rich" class="quick-btn">Verse of the Day</a>
<a href="/plan" class="quick-btn">Make a Bible Reading Plan</a>
<a href="/saved" class="quick-btn">Saved Verses &amp; Notes</a>
<a href="#" class="quick-btn" id="continue-plan" hidden>Continue My Reading Plan</a>
</div> </div>
</div> </div>
@@ -171,8 +260,9 @@
{{ book.title }} {{ book.title }}
</button> </button>
<div class="chapter-grid content"> <div class="chapter-grid content">
{% if book.about %}<p class="book-about">{{ book.about }}</p>{% endif %}
{% for chapter in range(1, book.chapters + 1) %} {% for chapter in range(1, book.chapters + 1) %}
<a href="/show?kw={{ book.title }} {{ chapter }}&view=rich" class="chapter-link">{{ chapter }}</a> <a href="/show?kw={{ book.title }} {{ chapter }}&view=rich" class="chapter-link"><span class="chapter-num">{{ chapter }}</span>{% if book.summaries|length >= chapter %}<span class="chapter-sum">{{ book.summaries[chapter - 1] }}</span>{% endif %}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -191,8 +281,9 @@
{{ book.title }} {{ book.title }}
</button> </button>
<div class="chapter-grid content"> <div class="chapter-grid content">
{% if book.about %}<p class="book-about">{{ book.about }}</p>{% endif %}
{% for chapter in range(1, book.chapters + 1) %} {% for chapter in range(1, book.chapters + 1) %}
<a href="/show?kw={{ book.title }} {{ chapter }}&view=rich" class="chapter-link">{{ chapter }}</a> <a href="/show?kw={{ book.title }} {{ chapter }}&view=rich" class="chapter-link"><span class="chapter-num">{{ chapter }}</span>{% if book.summaries|length >= chapter %}<span class="chapter-sum">{{ book.summaries[chapter - 1] }}</span>{% endif %}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -201,8 +292,8 @@
{% endfor %} {% endfor %}
<!-- API DOCUMENTATION --> <!-- API DOCUMENTATION -->
<button class="collapsible" style="margin-top: 2rem;">API Documentation</button> <details class="api-docs">
<div class="content"> <summary>API Documentation</summary>
<pre><code>API ENDPOINT SPECIFICATION v 0.3 <pre><code>API ENDPOINT SPECIFICATION v 0.3
================================ ================================
@@ -275,9 +366,19 @@ api.tld/show?kw=john3:16,1john5:7&view=plain
BUGS BUGS
==== ====
Suggestions, bug reports or improvements can be reported to <a href="https://nicecrew.digital/@tyler">@tyler@nicecrew.digital</a></code></pre> Suggestions, bug reports or improvements can be reported to <a href="https://nicecrew.digital/@tyler">@tyler@nicecrew.digital</a></code></pre>
</div> </details>
</div> </div>
<script>
try {
var plan = JSON.parse(localStorage.getItem('kjv.plan'));
if (plan && /^[0-9A-Z]+$/.test(plan.uid)) {
var link = document.getElementById('continue-plan');
link.href = '/plan/' + plan.uid + '?n=' + (parseInt(plan.n, 10) || 25);
link.hidden = false;
}
} catch (e) {}
</script>
</body> </body>
</html> </html>
+32 -25
View File
@@ -1,28 +1,35 @@
<div class="topbar" align="center"> <!DOCTYPE html>
<html lang="en">
</div> <head>
<meta charset="UTF-8">
<header> <meta name="viewport" content="width=device-width, initial-scale=1">
<div class="header" id="NavHeader" align="center"> <meta name="color-scheme" content="dark light">
<title>{title} - KJV API</title>
<a href="/">Home</a> | <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<a href="/random?view=rich">Random Verse</a> | {css}
<script src="/js/verse_menu.js?v=__V__" defer></script>
<script src="/js/book_suggest.js?v=__V__" defer></script>
</head>
<body>
<header class="site-header">
<div class="site-bar">
<a class="brand" href="/">KJV API</a>
<nav class="site-links" aria-label="Site">
<a href="/random?view=rich">Random Verse</a>
<a href="/votd?view=rich">Verse of the Day</a> <a href="/votd?view=rich">Verse of the Day</a>
<form action="/find"> <a href="/plan">Reading Plan</a>
<input type="text" name="kw" value="{query}" id="searchBar"> <a href="/saved">Saved</a>
<input type="submit" value="Search"> </nav>
<input type="hidden" name="view" value="rich"> </div>
</form> <form class="search-form" action="/find" role="search">
<button onclick="make_shortcode()">Get Shortcode: </button> <input type="text" name="kw" value="{query}" id="searchBar" data-book-suggest aria-label="Search scripture" placeholder="Verse reference or keywords..." autocapitalize="off" autocomplete="off" enterkeyhint="search">
<a id="context_href" href=''></a> <input type="hidden" name="view" value="rich">
<br> <input type="submit" value="Search">
&leftarrow; </form>
<a href="{prev_chapter_link}">{prev_chapter_name}</a> | <div class="shortcode">
{current_chapter_name} | <button type="button" class="btn-small" onclick="make_shortcode()">Get shortcode</button>
<a href="{next_chapter_link}">{next_chapter_name}</a> <a id="context_href" href=""></a>
&rightarrow;
</div> </div>
<script src="js/shortcode.js">
make_shortcode();
</script>
</header> </header>
{chapter_nav}
<script src="/js/shortcode.js?v=__V__"></script>
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Make a Bible Reading Plan - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>
.fieldset { border: none; padding: 0; margin: 0 0 var(--space-lg); }
.fieldset legend { font-weight: 600; padding: 0; margin-bottom: 0.25rem; }
.inline-field { display: inline-block; width: 6rem; margin: 0 0.4rem; }
.inline-field input[type=text], input[type=number] {
min-height: 44px; padding: 0.4rem 0.6rem; font-size: 1rem; font-family: var(--font-ui);
background-color: var(--color-bg-primary); color: var(--color-text-primary);
border: 2px solid var(--color-border); border-radius: var(--border-radius-sm);
}
select {
min-height: 44px; font-size: 1rem; font-family: var(--font-ui); margin-left: 0.4rem;
background-color: var(--color-bg-primary); color: var(--color-text-primary);
border: 2px solid var(--color-border); border-radius: var(--border-radius-sm); padding: 0 0.5rem;
}
input[type=number] { width: 6rem; margin: 0 0.4rem; }
.quick-start { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem; }
.quick-start button {
min-height: 40px; padding: 0.3rem 1rem; background-color: var(--color-bg-tertiary);
color: var(--color-text-primary); border: 1px solid var(--color-border);
}
.continue-box { display: none; background-color: var(--color-bg-secondary); border: 1px solid var(--color-accent-primary);
border-radius: var(--border-radius); padding: var(--space-md) var(--space-lg); margin: var(--space-md) 0; }
.continue-box a { font-weight: 600; }
</style>
<script src="/js/book_suggest.js?v=__V__" defer></script>
</head>
<body>
<header>
<a href="/">Home</a> <a href="/find">Quick Search</a>
<a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
<a href="/saved">Saved</a>
</header>
<h2>Make a Bible Reading Plan</h2>
<div class="continue-box" id="continue-box">
You already have a plan in this browser: <a id="continue-link" href="#">continue reading</a>.
</div>
<p>Pick where to start and how much to read each day. You'll get a page to bookmark; open it
each day to see today's reading, press <b>Done</b> when you've read it, and the plan moves on.
You can always go back to the previous reading or jump to any verse.</p>
<form action="/plan/new" method="post">
<div class="fieldset">
<label for="start">Start at</label>
<div class="quick-start">
<button type="button" data-start="Genesis 1:1">Genesis (whole Bible)</button>
<button type="button" data-start="Matthew 1:1">Matthew (New Testament)</button>
<button type="button" data-start="Psalms 1:1">Psalms</button>
<button type="button" data-start="John 1:1">John</button>
</div>
<input type="text" name="start" id="start" data-book-suggest placeholder="Genesis 1:1" autocapitalize="off" autocomplete="off">
</div>
<fieldset class="fieldset">
<legend>How much each day</legend>
<label class="choice"><input type="radio" name="pace" value="days" checked>
Finish the Bible in
<select name="days" aria-label="Finish in">
<option value="365" selected>1 year</option>
<option value="180">6 months</option>
<option value="90">90 days</option>
<option value="30">30 days</option>
</select>
</label><br>
<label class="choice"><input type="radio" name="pace" value="verses">
Read <input type="number" name="verses" value="30" min="1" max="500" aria-label="Verses per day"> verses a day
</label>
</fieldset>
<input type="submit" value="Create my plan">
</form>
<p>Your plan is saved on this server under a short code in the page address, so the bookmark
is all you need. Plans unused for 90 days are removed.</p>
<script>
var startInput = document.getElementById('start');
document.querySelectorAll('[data-start]').forEach(function (b) {
b.addEventListener('click', function () { startInput.value = b.getAttribute('data-start'); });
});
try {
var plan = JSON.parse(localStorage.getItem('kjv.plan'));
if (plan && /^[0-9A-Z]+$/.test(plan.uid)) {
document.getElementById('continue-link').href = '/plan/' + plan.uid + '?n=' + (parseInt(plan.n, 10) || 25);
document.getElementById('continue-box').style.display = 'block';
}
} catch (e) {}
</script>
</body>
</html>
+17
View File
@@ -1,3 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Books - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v={{ v }}">
</head>
<body>
<header>
<a href="/">Home</a> <a href="/find">Quick Search</a>
</header>
<h2>Books</h2>
<div class="table-wrap">
<table id="Books"> <table id="Books">
{% for book in books %} {% for book in books %}
<tr> <tr>
@@ -5,3 +19,6 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
<form action="/plan">
<input type="text" name="kw" value="{query}" id="searchBar">
<input type="submit" value="Search">
<input type="hidden" name="view" value="rich">
</form>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="verse-container"> <div class="verse-container" data-verse-id="{verse_id}">
<div class="verse-reference"><a href="/find?kw={chapter}&view=rich">{passage}</a></div> <div class="verse-reference"><a href="/find?kw={chapter}&view=rich">{passage}</a></div>
<div class="verse-text">{text}</div> <div class="verse-text">{text}</div>
</div> </div>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saved - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>
.item { background-color: var(--color-bg-secondary); border: 1px solid var(--color-border);
border-left: 3px solid var(--color-accent-primary); border-radius: var(--border-radius-sm);
padding: var(--space-sm) var(--space-md); margin-bottom: var(--space-sm); }
.item .ref { font-weight: 700; }
.item .text { font-family: var(--font-scripture); margin: 0.25rem 0 0.5rem; color: var(--color-text-primary); }
.item p { margin: 0.25rem 0; }
.item button { min-height: 36px; padding: 0.2rem 0.9rem; font-size: 0.85rem;
background-color: var(--color-bg-tertiary); color: var(--color-text-primary); border: 1px solid var(--color-border); }
.empty { color: var(--color-text-muted); }
.note { font-size: 0.9rem; color: var(--color-text-muted); }
</style>
</head>
<body>
<header>
<a href="/">Home</a> <a href="/find">Quick Search</a> <a href="/plan">Reading Plan</a>
<a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header>
<h2>Saved</h2>
<p class="note">Saved in this browser only. Clearing site data or using another device starts fresh.</p>
<h3>Favorite verses</h3>
<div id="fav"></div>
<h3>Definitions</h3>
<div id="defs"></div>
<h3>Cross references</h3>
<div id="xrefs"></div>
<script>
var KEYS = { fav: 'kjv.favorites', defs: 'kjv.definitions', xrefs: 'kjv.crossrefs' };
function load(key) {
try { var v = JSON.parse(localStorage.getItem(key)); return Array.isArray(v) ? v : []; }
catch (e) { return []; }
}
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined) n.textContent = text;
return n;
}
function findUrl(ref) { return '/find?kw=' + encodeURIComponent(ref) + '&view=rich'; }
function render(key, targetId, emptyText, build) {
var target = document.getElementById(targetId);
target.textContent = '';
var list = load(key);
if (!list.length) { target.appendChild(el('p', 'empty', emptyText)); return; }
list.forEach(function (item, i) {
var box = el('div', 'item');
build(box, item);
var remove = el('button', '', 'Remove');
remove.type = 'button';
remove.addEventListener('click', function () {
var current = load(key);
current.splice(i, 1);
try { localStorage.setItem(key, JSON.stringify(current)); } catch (e) {}
render(key, targetId, emptyText, build);
});
box.appendChild(remove);
target.appendChild(box);
});
}
function refLink(ref) {
var a = el('a', 'ref', ref);
a.href = findUrl(ref);
return a;
}
function draw() {
render(KEYS.fav, 'fav', 'No favorites yet. Tap a verse and choose Favorite.', function (box, f) {
box.appendChild(refLink(f.ref));
box.appendChild(el('div', 'text', f.text));
});
render(KEYS.defs, 'defs', 'No saved definitions. Tap a verse, choose Define words, and save one.', function (box, d) {
box.appendChild(el('strong', '', d.word));
if (d.ref) { box.appendChild(document.createTextNode(' from ')); box.appendChild(refLink(d.ref)); }
(d.defs || []).forEach(function (t) { box.appendChild(el('p', '', t)); });
});
render(KEYS.xrefs, 'xrefs', 'No saved cross references. Tap a verse, choose Cross references, and save one.', function (box, x) {
box.appendChild(refLink(x.ref));
box.appendChild(el('span', 'note', ' (cross reference of ' + x.from + ')'));
box.appendChild(el('div', 'text', x.text));
});
}
draw();
</script>
</body>
</html>
+10 -10
View File
@@ -4,22 +4,22 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bible Search - KJV API</title> <title>Bible Search - KJV API</title>
<link rel="stylesheet" href="/styles/page.css"> <link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>body{background-color:#1a1a1a}</style>
</head> </head>
<body> <body>
<header> <header>
<a href="/">Home</a> | <a href="/find">Quick Search</a> | <a href="/search">Bible Search</a> | <a href="/">Home</a> <a href="/find">Quick Search</a> <a href="/search">Bible Search</a>
<a href="/random?view=rich">Random Verse</a> | <a href="/votd?view=rich">Verse of the Day</a> <a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header> </header>
<h2>Bible Search</h2> <h2>Bible Search</h2>
<form action="/search"> <form action="/search">
<label for='kw'>Key Words</label> <label for="kw">Key Words</label>
<input type="text" name="kw" placeholder="Enter search keywords..."> <input type="text" name="kw" id="kw" autocapitalize="off" autocomplete="off" enterkeyhint="search" placeholder="Enter search keywords...">
<label for='view'>View</label> <div class="form-row"><b>View</b><br>
<input type="radio" name="view" value="rich" checked="checked"> Rich <label class="choice"><input type="radio" name="view" value="rich" checked="checked"> Rich</label>
<input type="radio" name="view" value="plain"> Plain <label class="choice"><input type="radio" name="view" value="plain"> Plain</label>
<input type="radio" name="view" value="json"> JSON <label class="choice"><input type="radio" name="view" value="json"> JSON</label>
</div>
<input type="submit" value="Search"> <input type="submit" value="Search">
</form> </form>
<hr> <hr>
+4 -5
View File
@@ -4,13 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sequential Bible Viewer - KJV API</title> <title>Sequential Bible Viewer - KJV API</title>
<link rel="stylesheet" href="/styles/page.css"> <link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>body{background-color:#1a1a1a}</style>
</head> </head>
<body> <body>
<header> <header>
<a href="/">Home</a> | <a href="/find">Quick Search</a> | <a href="/seq">Sequential Viewer</a> | <a href="/">Home</a> <a href="/find">Quick Search</a> <a href="/seq">Sequential Viewer</a>
<a href="/random?view=rich">Random Verse</a> | <a href="/votd?view=rich">Verse of the Day</a> <a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header> </header>
<h2>Sequential Bible Viewer</h2> <h2>Sequential Bible Viewer</h2>
<p>In order to use this, you first need a UID, which you can get by querying <code>/seq?getuid=true</code> and optionally include <code>&start=BIBLEREF</code> (where BIBLEREF is like 1Cor2:1) to set a start position other than Genesis 1:1.</p> <p>In order to use this, you first need a UID, which you can get by querying <code>/seq?getuid=true</code> and optionally include <code>&start=BIBLEREF</code> (where BIBLEREF is like 1Cor2:1) to set a start position other than Genesis 1:1.</p>
@@ -18,7 +17,7 @@
<p>The <code>&view=</code> argument works with this.</p> <p>The <code>&view=</code> argument works with this.</p>
<h4>Example Pleroma Bot shell script</h4> <h4>Example Pleroma Bot shell script</h4>
<pre><code>#!/bin/sh <pre><code>#!/bin/sh
verse=$(curl -XGET https://api.1611.social/seq?uid=MYUID?view=plain); verse=$(curl -XGET https://bible.nicecrew.digital/seq?uid=MYUID?view=plain);
curl -XPOST -u biblebot:MYPASSWORD \ curl -XPOST -u biblebot:MYPASSWORD \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"status": "'"${verse}"'"}' \ -d '{"status": "'"${verse}"'"}' \
+17
View File
@@ -1,3 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sequence Report - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v={{ v }}">
</head>
<body>
<header>
<a href="/">Home</a> <a href="/find">Quick Search</a>
</header>
<h2>Sequence Report</h2>
<div class="table-wrap">
<table id="seq_report"> <table id="seq_report">
{% for uid in uids %} {% for uid in uids %}
<tr> <tr>
@@ -7,3 +21,6 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
</body>
</html>
+21 -4
View File
@@ -1,14 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shortcode Report - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v={{ v }}">
</head>
<body>
<header>
<a href="/">Home</a> <a href="/find">Quick Search</a>
</header>
<h2>Shortcode Report</h2>
<div class="table-wrap">
<table id="shortcode_report"> <table id="shortcode_report">
<tr> <tr>
<td>shortcode |</td> <td>shortcode</td>
<td>query |</td> <td>query</td>
<td>creation_date</td> <td>creation_date</td>
</tr> </tr>
{% for code in shortcodes %} {% for code in shortcodes %}
<tr> <tr>
<td>{{ code[0] }} |</td> <td>{{ code[0] }}</td>
<td>{{ code[1] }} |</td> <td>{{ code[1] }}</td>
<td>{{ code[2] }}</td> <td>{{ code[2] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
</body>
</html>
+11 -10
View File
@@ -4,22 +4,23 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Show Verse - KJV API</title> <title>Show Verse - KJV API</title>
<link rel="stylesheet" href="/styles/page.css"> <link rel="stylesheet" href="/styles/page.css?v=__V__">
<style>body{background-color:#1a1a1a}</style> <script src="/js/book_suggest.js?v=__V__" defer></script>
</head> </head>
<body> <body>
<header> <header>
<a href="/">Home</a> | <a href="/find">Quick Search</a> | <a href="/show">Show Verse</a> | <a href="/">Home</a> <a href="/find">Quick Search</a> <a href="/show">Show Verse</a>
<a href="/random?view=rich">Random Verse</a> | <a href="/votd?view=rich">Verse of the Day</a> <a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header> </header>
<h2>Show Verse</h2> <h2>Show Verse</h2>
<form action="/show"> <form action="/show">
<label for='kw'>Verse References</label> <label for="kw">Verse References</label>
<input type="text" name="kw" placeholder="e.g., 1 John 5:7 or John3:16,2Peter2:1"> <input type="text" name="kw" id="kw" data-book-suggest autocapitalize="off" autocomplete="off" enterkeyhint="search" placeholder="e.g., 1 John 5:7 or John3:16,2Peter2:1">
<label for='view'>View</label> <div class="form-row"><b>View</b><br>
<input type="radio" name="view" value="rich" checked="checked"> Rich <label class="choice"><input type="radio" name="view" value="rich" checked="checked"> Rich</label>
<input type="radio" name="view" value="plain"> Plain <label class="choice"><input type="radio" name="view" value="plain"> Plain</label>
<input type="radio" name="view" value="json"> JSON <label class="choice"><input type="radio" name="view" value="json"> JSON</label>
</div>
<input type="submit" value="Show"> <input type="submit" value="Show">
</form> </form>
<hr> <hr>
+160
View File
@@ -0,0 +1,160 @@
// Book name suggestions for reference/search boxes: type "joh" and get
// John, 1 John...; Tab (or tap/click) completes. Only offered while the text
// could still be the start of a book name, so keyword searches are unaffected.
(function () {
var BOOKS = __BOOKS__;
var MAX_SHOWN = 8;
var list = null;
var current = { input: null, matches: [], active: 0, explicit: false };
function norm(text) {
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function injectStyle() {
var style = document.createElement("style");
style.textContent =
".book-suggest{position:absolute;z-index:2000;margin:4px 0 0;padding:4px 0;list-style:none;" +
"background:var(--color-bg-secondary,#fff);color:var(--color-text-primary,#111);" +
"border:1px solid var(--color-border,#ccc);border-radius:6px;" +
"box-shadow:0 6px 18px var(--color-shadow,rgba(0,0,0,.25));font:1rem var(--font-ui,sans-serif)}" +
".book-suggest li{padding:10px 14px;cursor:pointer;min-height:44px;display:flex;align-items:center}" +
".book-suggest li[aria-selected=true]{background:var(--color-accent-primary,#2b5c94);" +
"color:var(--color-on-accent,#fff)}" +
".book-suggest .hint{min-height:0;padding:4px 14px;font-size:.8rem;cursor:default;" +
"color:var(--color-text-muted,#666);background:none}" +
"@media (hover:none){.book-suggest .hint{display:none}}";
document.head.appendChild(style);
}
function ensureList() {
if (list) {
return list;
}
injectStyle();
list = document.createElement("ul");
list.className = "book-suggest";
list.id = "book-suggest-list";
list.setAttribute("role", "listbox");
list.hidden = true;
// keep focus in the input while clicking an item
list.addEventListener("mousedown", function (event) { event.preventDefault(); });
document.body.appendChild(list);
return list;
}
function close() {
if (list) {
list.hidden = true;
}
if (current.input) {
current.input.setAttribute("aria-expanded", "false");
current.input.removeAttribute("aria-activedescendant");
}
current.matches = [];
}
function accept(book) {
var input = current.input;
input.value = book + " ";
input.setSelectionRange(input.value.length, input.value.length);
close();
input.focus();
}
function paint() {
var items = list.querySelectorAll("li[role=option]");
for (var i = 0; i < items.length; i++) {
var selected = i === current.active;
items[i].setAttribute("aria-selected", selected ? "true" : "false");
if (selected) {
current.input.setAttribute("aria-activedescendant", items[i].id);
}
}
}
function show(input, matches) {
var box = input.getBoundingClientRect();
ensureList();
list.textContent = "";
matches.forEach(function (book, i) {
var item = document.createElement("li");
item.setAttribute("role", "option");
item.id = "book-suggest-" + i;
item.textContent = book;
item.addEventListener("click", function () { accept(book); });
list.appendChild(item);
});
var hint = document.createElement("li");
hint.className = "hint";
hint.textContent = "Tab to complete";
list.appendChild(hint);
list.style.left = box.left + window.pageXOffset + "px";
list.style.top = box.bottom + window.pageYOffset + "px";
list.style.minWidth = Math.min(box.width, 320) + "px";
list.hidden = false;
input.setAttribute("aria-expanded", "true");
paint();
}
function update(input) {
current.input = input;
var value = input.value.replace(/^\s+/, "");
// "1 jo", "song of sol": optional leading book number, then letters only
var n = /^[1-3]?\s?[A-Za-z ]*$/.test(value) ? norm(value) : "";
var matches = n ? BOOKS.filter(function (b) { return norm(b).indexOf(n) === 0; }) : [];
// already typed a whole book name: nothing left to complete
if (matches.length === 1 && norm(matches[0]) === n) {
matches = [];
}
matches = matches.slice(0, MAX_SHOWN);
current.matches = matches;
current.active = 0;
current.explicit = false;
if (matches.length) {
show(input, matches);
} else {
close();
}
}
function onKeydown(event) {
var open = list && !list.hidden && current.matches.length;
if (!open) {
return;
}
var count = current.matches.length;
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
accept(current.matches[current.active]);
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
current.active = (current.active + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
current.explicit = true;
paint();
} else if (event.key === "Enter" && current.explicit) {
event.preventDefault(); // pick the highlighted book instead of submitting
accept(current.matches[current.active]);
} else if (event.key === "Escape") {
close();
}
}
function attach(input) {
input.setAttribute("autocomplete", "off");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", "book-suggest-list");
input.addEventListener("input", function () { update(input); });
input.addEventListener("keydown", function (event) {
current.input = input;
onKeydown(event);
});
input.addEventListener("blur", close);
}
var inputs = document.querySelectorAll("input[data-book-suggest]");
for (var i = 0; i < inputs.length; i++) {
attach(inputs[i]);
}
window.addEventListener("resize", close);
})();
+21 -17
View File
@@ -1,10 +1,28 @@
var coll = document.getElementsByClassName("collapsible"); var coll = document.getElementsByClassName("collapsible");
var i; var i;
function setOpen(button, open) {
var content = button.nextElementSibling;
button.classList.toggle("active", open);
button.setAttribute("aria-expanded", open ? "true" : "false");
if (!content) {
return;
}
if (!open) {
content.style.display = "none";
} else if (content.classList.contains('chapter-grid')) {
content.style.display = "grid";
} else {
content.style.display = "block";
}
}
for (i = 0; i < coll.length; i++) { for (i = 0; i < coll.length; i++) {
coll[i].setAttribute("aria-expanded", "false");
coll[i].addEventListener("click", function() { coll[i].addEventListener("click", function() {
var content = this.nextElementSibling; var content = this.nextElementSibling;
var isCurrentlyOpen = content.style.display === "block" || content.style.display === "grid"; // computed style, so content hidden by a stylesheet counts as closed
var isCurrentlyOpen = content && getComputedStyle(content).display !== "none";
// If this collapsible has a parent with book-grid class, close all siblings in that grid // If this collapsible has a parent with book-grid class, close all siblings in that grid
var parentGrid = this.closest('.book-grid'); var parentGrid = this.closest('.book-grid');
@@ -12,25 +30,11 @@ for (i = 0; i < coll.length; i++) {
var siblings = parentGrid.querySelectorAll('.collapsible'); var siblings = parentGrid.querySelectorAll('.collapsible');
for (var j = 0; j < siblings.length; j++) { for (var j = 0; j < siblings.length; j++) {
if (siblings[j] !== this) { if (siblings[j] !== this) {
siblings[j].classList.remove("active"); setOpen(siblings[j], false);
var siblingContent = siblings[j].nextElementSibling;
if (siblingContent) {
siblingContent.style.display = "none";
}
} }
} }
} }
// Toggle current element setOpen(this, !isCurrentlyOpen);
this.classList.toggle("active");
if (isCurrentlyOpen) {
content.style.display = "none";
} else {
if (content.classList.contains('chapter-grid')) {
content.style.display = "grid";
} else {
content.style.display = "block";
}
}
}); });
} }
+265
View File
@@ -0,0 +1,265 @@
// Tap a verse to open a small menu: favorite it, look up the 1828 Webster
// definition of its words, or see its cross references. Everything saved
// lives in this browser's localStorage (read back on the /saved page).
(function () {
var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs" };
var open = null; // { box, menu }
function load(key) {
try {
var value = JSON.parse(localStorage.getItem(key));
return Array.isArray(value) ? value : [];
} catch (e) {
return [];
}
}
function save(key, list) {
try {
localStorage.setItem(key, JSON.stringify(list));
return true;
} catch (e) {
return false;
}
}
function indexOf(list, match) {
for (var i = 0; i < list.length; i++) {
if (match(list[i])) {
return i;
}
}
return -1;
}
// Adds the item if it is not stored yet, removes it if it is. Returns the new "saved" state.
function toggle(key, item, match) {
var list = load(key);
var at = indexOf(list, match);
if (at >= 0) {
list.splice(at, 1);
} else {
item.t = Date.now();
list.unshift(item);
}
save(key, list);
return at < 0;
}
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
function verseInfo(box) {
var link = box.querySelector(".verse-reference a");
var text = box.querySelector(".verse-text");
return {
ref: link ? link.textContent.trim() : "",
text: text ? text.textContent.trim() : "",
id: box.getAttribute("data-verse-id") || ""
};
}
function findUrl(ref) {
return "/find?kw=" + encodeURIComponent(ref) + "&view=rich";
}
function markFavorites(root) {
var favs = load(KEYS.fav);
var boxes = (root || document).querySelectorAll(".verse-container");
for (var i = 0; i < boxes.length; i++) {
var ref = verseInfo(boxes[i]).ref;
boxes[i].classList.toggle("is-fav", indexOf(favs, function (f) { return f.ref === ref; }) >= 0);
}
}
function closeMenu() {
if (open) {
open.box.classList.remove("selected");
open.box.removeAttribute("aria-expanded");
open.menu.remove();
open = null;
}
}
function saveButton(isSaved, onToggle) {
var button = el("button", "vm-save");
button.type = "button";
function paint(saved) {
button.textContent = saved ? "Saved ✓" : "Save";
button.classList.toggle("saved", saved);
}
paint(isSaved);
button.addEventListener("click", function () { paint(onToggle()); });
return button;
}
function showDefinitions(info, panel) {
panel.textContent = "Looking up words…";
var seen = {};
var words = (info.text.toLowerCase().match(/[a-z]+/g) || []).filter(function (w) {
if (w.length < 3 || seen[w]) {
return false;
}
seen[w] = true;
return true;
});
fetch("/defs?words=" + encodeURIComponent(words.join(",")))
.then(function (r) { return r.json(); })
.then(function (defs) {
panel.textContent = "";
var found = words.filter(function (w) { return defs[w]; });
if (!found.length) {
panel.textContent = "No definitions found for the words in this verse.";
return;
}
var chips = el("div", "vm-chips");
var detail = el("div", "vm-detail");
detail.hidden = true;
found.forEach(function (word) {
var chip = el("button", "vm-chip", word);
chip.type = "button";
chip.addEventListener("click", function () {
detail.hidden = false;
detail.textContent = "";
detail.appendChild(el("strong", "", word));
defs[word].forEach(function (d) { detail.appendChild(el("p", "", d)); });
var match = function (s) { return s.word === word; };
detail.appendChild(saveButton(
indexOf(load(KEYS.defs), match) >= 0,
function () {
return toggle(KEYS.defs, { word: word, defs: defs[word], ref: info.ref }, match);
}
));
});
chips.appendChild(chip);
});
panel.appendChild(el("div", "vm-hint", "Tap a word for its 1828 Webster definition."));
panel.appendChild(chips);
panel.appendChild(detail);
})
.catch(function () { panel.textContent = "Could not load definitions."; });
}
function showCrossRefs(info, panel) {
if (!info.id) {
panel.textContent = "Cross references are not available here.";
return;
}
panel.textContent = "Loading cross references…";
fetch("/xref/" + encodeURIComponent(info.id))
.then(function (r) { return r.json(); })
.then(function (refs) {
panel.textContent = "";
if (!refs.length) {
panel.textContent = "No cross references for this verse.";
return;
}
refs.forEach(function (x) {
var row = el("div", "vm-xref");
var link = el("a", "", x.ref);
link.href = findUrl(x.ref);
row.appendChild(link);
row.appendChild(el("div", "vm-xref-text", x.text));
var match = function (s) { return s.from === info.ref && s.ref === x.ref; };
row.appendChild(saveButton(
indexOf(load(KEYS.xrefs), match) >= 0,
function () {
return toggle(KEYS.xrefs, { from: info.ref, ref: x.ref, text: x.text }, match);
}
));
panel.appendChild(row);
});
})
.catch(function () { panel.textContent = "Could not load cross references."; });
}
function openMenu(box) {
closeMenu();
var info = verseInfo(box);
var menu = el("div", "verse-menu");
menu.setAttribute("role", "group");
menu.setAttribute("aria-label", "Actions for " + info.ref);
var actions = el("div", "vm-actions");
var panel = el("div", "vm-panel");
panel.hidden = true;
var favMatch = function (f) { return f.ref === info.ref; };
var fav = el("button", "vm-btn");
fav.type = "button";
function paintFav() {
var saved = indexOf(load(KEYS.fav), favMatch) >= 0;
fav.textContent = saved ? "★ Favorited" : "☆ Favorite";
fav.classList.toggle("saved", saved);
box.classList.toggle("is-fav", saved);
}
paintFav();
fav.addEventListener("click", function () {
toggle(KEYS.fav, { ref: info.ref, text: info.text }, favMatch);
paintFav();
});
var define = el("button", "vm-btn", "Define words");
define.type = "button";
define.addEventListener("click", function () {
panel.hidden = false;
showDefinitions(info, panel);
});
var xref = el("button", "vm-btn", "Cross references");
xref.type = "button";
xref.addEventListener("click", function () {
panel.hidden = false;
showCrossRefs(info, panel);
});
var close = el("button", "vm-btn vm-close", "×");
close.type = "button";
close.setAttribute("aria-label", "Close menu");
close.addEventListener("click", closeMenu);
[fav, define, xref, close].forEach(function (b) { actions.appendChild(b); });
menu.appendChild(actions);
menu.appendChild(panel);
box.appendChild(menu);
box.classList.add("selected");
box.setAttribute("aria-expanded", "true");
open = { box: box, menu: menu };
}
document.addEventListener("click", function (event) {
var box = event.target.closest && event.target.closest(".verse-container");
if (!box) {
closeMenu();
return;
}
// links, buttons and the open menu keep their own behaviour; so does selecting text
if (event.target.closest("a, button, input, .verse-menu")) {
return;
}
var selection = window.getSelection && window.getSelection().toString();
if (selection) {
return;
}
if (open && open.box === box) {
closeMenu();
} else {
openMenu(box);
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
closeMenu();
}
});
markFavorites();
})();
+31 -28
View File
@@ -3,46 +3,49 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search</title> <title>Semantic Search - KJV API</title>
<link rel="stylesheet" href="/styles/page.css?v=__V__">
<style> <style>
#search-box { .search-row { display: flex; gap: var(--space-sm); }
padding: 10px; .search-row input[type=text] { margin: 0; flex: 1; min-width: 0; }
border: 1px solid #ccc; #results, #last-searched { list-style: none; padding: 0; margin: var(--space-md) 0; }
border-radius: 4px;
width: 100%;
margin-bottom: 10px;
}
#results {
list-style: none;
padding: 0;
}
#results li { #results li {
margin-bottom: 10px; background-color: var(--color-bg-secondary);
border-bottom: 1px solid #eee; border: 1px solid var(--color-border);
padding: 5px; border-left: 3px solid var(--color-accent-primary);
border-radius: var(--border-radius-sm);
padding: var(--space-md);
margin-bottom: var(--space-sm);
} }
#last-searched li { padding: 0.2rem 0; color: var(--color-text-secondary); }
@media (max-width: 480px) { .search-row { flex-direction: column; } .search-row button { width: 100%; } }
</style> </style>
</head> </head>
<body> <body>
<h1>Bible Semantic Search</h1> <header>
<p>This is NOT a keyword search. This means searching single words will not have the results you expect. For that, use the normal keyword search at <a href='/'>api.1611.social</a></p> <a href="/">Home</a> <a href="/find">Quick Search</a>
<a href="/random?view=rich">Random Verse</a> <a href="/votd?view=rich">Verse of the Day</a>
</header>
<h2>Bible Semantic Search</h2>
<p>This is NOT a keyword search. This means searching single words will not have the results you expect. For that, use the normal keyword search at <a href='/'>bible.nicecrew.digital</a></p>
<p>Use a sentence like "The Lord will kill his enemies without mercy" - you are supposed to search an idea, not individual words.</p> <p>Use a sentence like "The Lord will kill his enemies without mercy" - you are supposed to search an idea, not individual words.</p>
<h3>Recent searches:</h3> <h3>Recent searches</h3>
<ul id="last-searched"></ul> <ul id="last-searched"></ul>
<input type="text" id="search-box" placeholder="The lord god is a man of war, just in all his dealings"> <div class="search-row">
<input type="text" id="search-box" aria-label="Search an idea" enterkeyhint="search" placeholder="The lord god is a man of war, just in all his dealings">
<button id="search-button">Search</button> <button id="search-button">Search</button>
</div>
<ul id="results"></ul> <ul id="results"></ul>
<script> <script>
const searchButton = document.getElementById('search-button'); const searchButton = document.getElementById('search-button');
const searchBox = document.getElementById('search-box'); const searchBox = document.getElementById('search-box');
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const resultsList = document.getElementById('results'); const resultsList = document.getElementById('results');
const lastSearched = document.getElementById('last-searched'); const lastSearched = document.getElementById('last-searched');
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
const lastSearchedURL = `https://api.1611.social/natural`; const lastSearchedURL = `https://bible.nicecrew.digital/natural`;
const data = { const data = {
token: 'testing', token: 'testing',
query: '__last_searched__' query: '__last_searched__'
@@ -61,7 +64,7 @@
results.forEach(result => { results.forEach(result => {
const listItem = document.createElement('li'); const listItem = document.createElement('li');
listItem.innerHTML = ` listItem.innerHTML = `
${result.query}<br> ${esc(result.query)}<br>
`; `;
lastSearched.appendChild(listItem); lastSearched.appendChild(listItem);
}); });
@@ -75,7 +78,7 @@
if (query.length >= 100){ if (query.length >= 100){
alert("Query too long"); alert("Query too long");
return;} return;}
const url = `https://api.1611.social/natural`; // Replace with your actual URL const url = `https://bible.nicecrew.digital/natural`; // Replace with your actual URL
const data = { const data = {
token: 'testing', token: 'testing',
@@ -98,10 +101,10 @@
const listItem = document.createElement('li'); const listItem = document.createElement('li');
const uriRef = encodeURIComponent(result.ref); const uriRef = encodeURIComponent(result.ref);
listItem.innerHTML = ` listItem.innerHTML = `
<strong>Verse ID:</strong> ${result.verse_id}<br> <strong>Verse ID:</strong> ${esc(result.verse_id)}<br>
<strong>Text:</strong> ${result.text}<br> <strong>Text:</strong> ${esc(result.text)}<br>
<strong>Ref:</strong> <a href=/find?kw=${uriRef}>${result.ref}</a><br> <strong>Ref:</strong> <a href="/find?kw=${uriRef}">${esc(result.ref)}</a><br>
<strong>Score:</strong> ${result.score} <strong>Score:</strong> ${esc(result.score)}
`; `;
resultsList.appendChild(listItem); resultsList.appendChild(listItem);
}); });
+37 -27
View File
@@ -1,54 +1,64 @@
/* Modern Collapsible Styling */ /* Collapsible Styling (dictionary definitions on /find) */
.collapsible { .collapsible {
background-color: #2f2f2f; background-color: var(--color-bg-tertiary);
color: #e8e8e8; color: var(--color-text-primary);
cursor: pointer; cursor: pointer;
padding: 1rem 1.25rem; padding: 0.85rem 1.1rem;
min-height: 44px;
width: 100%; width: 100%;
max-width: 820px; max-width: var(--content-max-width);
border: 2px solid #3a3a3a; border: 2px solid var(--color-border);
border-radius: 4px; border-radius: var(--border-radius-sm);
text-align: left; text-align: left;
outline: none; outline: none;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: var(--font-ui);
transition: background-color 150ms ease, border-color 150ms ease, transform 150ms ease; transition: background-color 150ms ease, border-color 150ms ease;
margin: 0.5rem auto; margin: 0.5rem auto 0;
display: block; display: block;
} }
.collapsible::after {
content: "+";
float: right;
font-weight: 700;
}
.collapsible.active::after {
content: "\2212";
}
.collapsible:hover { .collapsible:hover {
background-color: #3a3a3a; border-color: var(--color-accent-primary);
border-color: #6b9bd1; transform: none;
transform: translateY(-1px); }
.collapsible:focus-visible {
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(107, 155, 209, 0.3);
} }
.collapsible.active { .collapsible.active {
background-color: #6b9bd1; background-color: var(--color-accent-primary);
color: #1a1a1a; color: var(--color-on-accent);
border-color: #6b9bd1; border-color: var(--color-accent-primary);
} }
.collapsible.active:hover { .collapsible.active:hover {
background-color: #85aede; background-color: var(--color-accent-hover);
border-color: #85aede; border-color: var(--color-accent-hover);
} }
/* Collapsible content styling */ /* Collapsible content styling */
.content { .content {
padding: 0 1.25rem;
display: none; display: none;
overflow: hidden; overflow: hidden;
background-color: #252525; background-color: var(--color-bg-secondary);
max-width: 820px; max-width: var(--content-max-width);
margin: 0 auto 0.5rem; margin: 0 auto 0.5rem;
border-radius: 4px;
border: 1px solid #3a3a3a;
}
.content.show {
display: block;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
} }
+514 -120
View File
@@ -1,7 +1,9 @@
<style> <style>
/* Modern KJV API Styling - Header & Layout */ /* KJV API Styling - rich (human readable) pages: header, chapter bar, verses */
:root { :root {
color-scheme: dark light;
/* Color Palette */ /* Color Palette */
--color-bg-primary: #1a1a1a; --color-bg-primary: #1a1a1a;
--color-bg-secondary: #252525; --color-bg-secondary: #252525;
@@ -11,6 +13,7 @@
--color-text-muted: #888888; --color-text-muted: #888888;
--color-accent-primary: #6b9bd1; --color-accent-primary: #6b9bd1;
--color-accent-hover: #85aede; --color-accent-hover: #85aede;
--color-on-accent: #121212;
--color-link: #6b9bd1; --color-link: #6b9bd1;
--color-link-hover: #85aede; --color-link-hover: #85aede;
--color-border: #3a3a3a; --color-border: #3a3a3a;
@@ -31,7 +34,6 @@
/* Layout */ /* Layout */
--content-max-width: 820px; --content-max-width: 820px;
--header-height: 160px;
--border-radius: 8px; --border-radius: 8px;
--border-radius-sm: 4px; --border-radius-sm: 4px;
@@ -40,10 +42,37 @@
--transition-normal: 250ms ease; --transition-normal: 250ms ease;
} }
@media (prefers-color-scheme: light) {
:root {
--color-bg-primary: #f7f5f0;
--color-bg-secondary: #ffffff;
--color-bg-tertiary: #ece9e1;
--color-text-primary: #1f1f1f;
--color-text-secondary: #4a4a4a;
--color-text-muted: #6b6b6b;
--color-accent-primary: #2b5c94;
--color-accent-hover: #1d4574;
--color-on-accent: #ffffff;
--color-link: #2b5c94;
--color-link-hover: #1d4574;
--color-border: #d6d1c4;
--color-shadow: rgba(0, 0, 0, 0.1);
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
[hidden] {
display: none !important;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body { body {
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
@@ -51,47 +80,167 @@ body {
padding: 0; padding: 0;
font-family: var(--font-ui); font-family: var(--font-ui);
line-height: 1.6; line-height: 1.6;
overflow-wrap: break-word;
} }
/* Header Styling */ a {
.header {
font-family: var(--font-ui);
padding: var(--space-lg);
background: linear-gradient(to bottom, var(--color-bg-secondary), var(--color-bg-tertiary));
color: var(--color-text-primary);
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100%;
z-index: 1000;
box-shadow: 0 4px 12px var(--color-shadow);
border-bottom: 1px solid var(--color-border);
}
.header a {
color: var(--color-link); color: var(--color-link);
text-decoration: none; text-decoration: none;
padding: var(--space-xs) var(--space-sm);
transition: color var(--transition-fast); transition: color var(--transition-fast);
}
a:hover {
color: var(--color-link-hover);
text-decoration: underline;
}
:focus-visible {
outline: 2px solid var(--color-accent-primary);
outline-offset: 2px;
}
/* ---- Site header: brand, links, search (scrolls away with the page) ---- */
.site-header {
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
padding: var(--space-sm) max(var(--space-md), env(safe-area-inset-right)) var(--space-sm) max(var(--space-md), env(safe-area-inset-left));
}
.site-bar,
.search-form,
.shortcode {
max-width: var(--content-max-width);
margin: 0 auto;
}
.site-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-md);
}
.brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--color-text-primary);
padding: var(--space-sm) 0;
}
.brand:hover {
color: var(--color-accent-primary);
text-decoration: none;
}
.site-links {
display: flex;
flex-wrap: wrap;
gap: 0 var(--space-xs);
}
.site-links a {
padding: 0.5rem 0.75rem;
border-radius: 999px;
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
}
.site-links a:hover {
background-color: var(--color-bg-tertiary);
text-decoration: none;
}
.search-form {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.shortcode {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
font-size: 0.9rem;
min-height: 1.5rem;
}
/* ---- Chapter bar: sticks to the top while scrolling a chapter ---- */
.chapter-nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: 0 max(var(--space-md), env(safe-area-inset-right)) 0 max(var(--space-md), env(safe-area-inset-left));
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
box-shadow: 0 2px 8px var(--color-shadow);
font-weight: 500; font-weight: 500;
} }
.header a:hover { .chapter-nav > * {
color: var(--color-link-hover); min-height: 48px;
display: flex;
align-items: center;
gap: var(--space-sm);
} }
/* Response Container */ .chapter-nav a {
flex: 1 1 0;
min-width: 0;
font-size: 1rem;
}
.chapter-nav a .nav-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chapter-nav a span[aria-hidden] {
font-size: 1.6rem;
line-height: 1;
}
.chapter-prev {
justify-content: flex-start;
}
.chapter-next {
justify-content: flex-end;
}
.chapter-nav .chapter-current {
flex: 0 1 auto;
justify-content: center;
font-weight: 700;
color: var(--color-text-primary);
text-align: center;
padding: 0 var(--space-sm);
text-decoration: underline;
text-decoration-color: var(--color-border);
text-underline-offset: 4px;
}
.chapter-nav .chapter-current:hover {
color: var(--color-accent-primary);
text-decoration-color: var(--color-accent-primary);
}
/* ---- Content column ---- */
.responses { .responses {
font-family: var(--font-scripture); font-family: var(--font-scripture);
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.8; line-height: 1.8;
max-width: var(--content-max-width); max-width: calc(var(--content-max-width) + 2 * var(--space-md));
margin: 0 auto; margin: 0 auto;
padding: var(--space-md); padding: var(--space-lg) max(var(--space-md), env(safe-area-inset-right)) var(--space-2xl) max(var(--space-md), env(safe-area-inset-left));
margin-top: calc(var(--header-height) + var(--space-md));
color: var(--color-text-primary); color: var(--color-text-primary);
white-space: pre-wrap;
} }
/* Correction & Variant Notices */ /* Correction & Variant Notices */
@@ -100,6 +249,7 @@ body {
.theological-notice { .theological-notice {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6;
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
background-color: var(--color-bg-tertiary); background-color: var(--color-bg-tertiary);
@@ -139,27 +289,33 @@ body {
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
font-family: var(--font-ui);
line-height: 1.6;
} }
.theological-suggestions h3 { .theological-suggestions h3 {
font-family: var(--font-ui);
font-size: 1.25rem; font-size: 1.25rem;
color: var(--color-text-primary); color: var(--color-text-primary);
margin: 0 0 var(--space-md) 0; margin: 0 0 var(--space-md) 0;
font-weight: 600; font-weight: 600;
} }
.theological-suggestions p {
color: var(--color-text-secondary);
margin: 0 0 var(--space-md) 0;
}
.suggestion-item { .suggestion-item {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 1rem; font-size: 1rem;
margin: var(--space-sm) 0; /* whole row is tappable height */
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
min-height: 44px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.suggestion-item a { .suggestion-item a {
color: var(--color-accent-primary); color: var(--color-accent-primary);
text-decoration: none;
transition: color var(--transition-fast); transition: color var(--transition-fast);
} }
@@ -180,44 +336,32 @@ body {
/* Dictionary Section */ /* Dictionary Section */
.dictionary-section { .dictionary-section {
margin: var(--space-xl) 0 var(--space-lg) 0; margin: var(--space-2xl) 0 var(--space-sm) 0;
font-family: var(--font-ui);
line-height: 1.5;
} }
.dictionary-section h3 { .dictionary-section h3 {
font-family: var(--font-ui);
font-size: 1.5rem; font-size: 1.5rem;
color: var(--color-text-primary); color: var(--color-text-primary);
margin-bottom: var(--space-sm); margin: 0 0 var(--space-xs) 0;
} }
.dictionary-section p { .dictionary-section p {
font-family: var(--font-ui);
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin: 0; margin: 0;
} }
/* Links */
a {
color: var(--color-link);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-link-hover);
text-decoration: underline;
}
/* Form Inputs */ /* Form Inputs */
input[type=text] { input[type=text] {
width: 100%; flex: 1;
max-width: 500px; min-width: 0;
min-height: 44px;
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
margin: var(--space-sm) 0; font-size: 1rem; /* 16px minimum stops iOS zooming into the field */
font-size: 1rem;
font-family: var(--font-ui); font-family: var(--font-ui);
background-color: var(--color-bg-tertiary); background-color: var(--color-bg-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
@@ -227,7 +371,7 @@ input[type=text] {
input[type=text]:focus { input[type=text]:focus {
outline: none; outline: none;
border-color: var(--color-accent-primary); border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(107, 155, 209, 0.1); box-shadow: 0 0 0 3px rgba(107, 155, 209, 0.25);
} }
input[type=text]::placeholder { input[type=text]::placeholder {
@@ -243,13 +387,13 @@ button {
font-weight: 600; font-weight: 600;
background-color: var(--color-accent-primary); background-color: var(--color-accent-primary);
border: none; border: none;
color: var(--color-bg-primary); color: var(--color-on-accent);
padding: var(--space-sm) var(--space-lg); padding: var(--space-sm) var(--space-lg);
min-height: 44px;
text-decoration: none; text-decoration: none;
margin: var(--space-xs) var(--space-sm);
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
transition: background-color var(--transition-fast), transform var(--transition-fast); transition: background-color var(--transition-fast);
font-size: 0.95rem; font-size: 0.95rem;
} }
@@ -258,39 +402,45 @@ input[type=submit]:hover,
input[type=reset]:hover, input[type=reset]:hover,
button:hover { button:hover {
background-color: var(--color-accent-hover); background-color: var(--color-accent-hover);
transform: translateY(-1px);
} }
input[type=button]:active, .btn-small {
input[type=submit]:active, min-height: 36px;
input[type=reset]:active, padding: var(--space-xs) var(--space-md);
button:active { font-size: 0.85rem;
transform: translateY(0); background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
} }
/* Definition Styling */ .btn-small:hover {
background-color: var(--color-bg-primary);
border-color: var(--color-accent-primary);
}
/* Definition Styling (collapsed until its word is tapped) */
.definition { .definition {
display: none;
padding: var(--space-lg); padding: var(--space-lg);
font-family: var(--font-scripture); font-family: var(--font-scripture);
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.7; line-height: 1.7;
max-width: var(--content-max-width); max-width: var(--content-max-width);
margin: var(--space-md) auto; margin: 0 auto var(--space-sm);
color: var(--color-text-secondary); color: var(--color-text-secondary);
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
border-left: 4px solid var(--color-accent-primary); border-left: 4px solid var(--color-accent-primary);
border-radius: var(--border-radius-sm); border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
} }
/* Verse Container Styling */ /* Verse cards */
.verse-container { .verse-container {
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
border-left: 3px solid var(--color-accent-primary); border-left: 3px solid var(--color-accent-primary);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: 0.5rem 1rem; padding: var(--space-sm) var(--space-md) var(--space-md);
margin: 0.15rem 0; margin: var(--space-sm) 0;
transition: background-color var(--transition-fast), border-color var(--transition-fast); transition: background-color var(--transition-fast), border-color var(--transition-fast);
line-height: 1;
} }
.verse-container:hover { .verse-container:hover {
@@ -302,19 +452,18 @@ button:active {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
margin: 0;
padding: 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
line-height: 1.2; line-height: 1.6;
display: block; display: block;
} }
.verse-reference a { .verse-reference a {
color: var(--color-accent-primary); color: var(--color-accent-primary);
text-decoration: none; /* a roomier hit area than the 0.8rem text alone */
transition: color var(--transition-fast); display: inline-block;
padding: var(--space-xs) 0;
} }
.verse-reference a:hover { .verse-reference a:hover {
@@ -324,76 +473,321 @@ button:active {
.verse-text { .verse-text {
font-family: var(--font-scripture); font-family: var(--font-scripture);
font-size: 1.15rem; font-size: 1.15rem;
line-height: 1.6; line-height: 1.7;
color: var(--color-text-primary); color: var(--color-text-primary);
text-indent: 0;
margin: 0.25rem 0 0 0;
padding: 0;
display: block; display: block;
} }
/* Responsive Design */ /* ---- Verse menu (tap a verse) ---- */
@media (max-width: 768px) { .verse-container {
:root { cursor: pointer;
--header-height: 140px;
--space-xs: 0.2rem;
--space-sm: 0.4rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.25rem;
} }
.header { .verse-container.selected {
padding: var(--space-md); background-color: var(--color-bg-tertiary);
border-left-color: var(--color-accent-hover);
}
.verse-container.is-fav .verse-reference::after {
content: " \2605";
color: #d9a93b;
letter-spacing: 0;
}
.verse-menu {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border);
font-family: var(--font-ui);
font-size: 0.95rem;
line-height: 1.5;
cursor: default;
}
.vm-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.vm-btn,
.vm-chip,
.vm-save {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
font-size: 0.9rem;
font-weight: 600;
padding: var(--space-xs) var(--space-md);
min-height: 40px;
}
.vm-btn:hover,
.vm-chip:hover,
.vm-save:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-accent-primary);
}
.vm-btn.saved,
.vm-save.saved {
background-color: var(--color-accent-primary);
border-color: var(--color-accent-primary);
color: var(--color-on-accent);
}
.vm-close {
margin-left: auto;
min-width: 40px;
padding: var(--space-xs) var(--space-sm);
font-size: 1.2rem;
line-height: 1;
}
.vm-panel {
margin-top: var(--space-sm);
max-height: 22rem;
overflow-y: auto;
color: var(--color-text-secondary);
}
.vm-hint {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
.vm-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-sm);
}
.vm-chip {
min-height: 36px;
border-radius: 999px;
font-weight: 500;
}
.vm-detail {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background-color: var(--color-bg-primary);
border-left: 3px solid var(--color-accent-primary);
border-radius: var(--border-radius-sm);
font-family: var(--font-scripture);
font-size: 1rem;
}
.vm-detail p {
margin: var(--space-xs) 0 var(--space-sm);
}
.vm-xref {
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--color-border);
}
.vm-xref:last-child {
border-bottom: none;
}
.vm-xref > a {
font-weight: 700;
display: inline-block;
padding: var(--space-xs) 0;
}
.vm-xref-text {
font-family: var(--font-scripture);
font-size: 1rem;
color: var(--color-text-primary);
margin-bottom: var(--space-xs);
}
.vm-save {
min-height: 36px;
margin-top: var(--space-xs);
}
/* ---- Reading plan ---- */
.plan-summary {
font-family: var(--font-ui);
padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-md);
background-color: var(--color-bg-tertiary);
border-left: 4px solid var(--color-accent-primary);
border-radius: var(--border-radius-sm);
line-height: 1.5;
}
.plan-range {
font-size: 1.35rem;
font-weight: 700;
color: var(--color-text-primary);
}
.plan-meta {
font-size: 0.9rem;
color: var(--color-text-muted);
}
.plan-progress {
height: 6px;
margin-top: var(--space-sm);
background-color: var(--color-border);
border-radius: 3px;
overflow: hidden;
}
.plan-progress span {
display: block;
height: 100%;
min-width: 2px;
background-color: var(--color-accent-primary);
}
.plan-controls {
font-family: var(--font-ui);
margin-top: var(--space-lg);
padding: var(--space-lg);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
}
.plan-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.plan-form + .plan-form {
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
.plan-form label {
font-weight: 600;
}
.plan-primary,
.plan-secondary {
min-height: 48px;
width: 100%;
}
.plan-primary {
font-size: 1.05rem;
}
.plan-secondary {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.plan-secondary:hover:not(:disabled) {
background-color: var(--color-bg-primary);
border-color: var(--color-accent-primary);
}
.plan-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.plan-goto-row {
display: flex;
gap: var(--space-sm);
}
.plan-goto-row .plan-secondary {
width: auto;
}
.plan-note {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--color-text-muted);
margin-top: var(--space-md);
}
/* ---- Wide screens: more air around the reading column ---- */
@media (min-width: 900px) {
.site-header {
padding-top: var(--space-md);
padding-bottom: var(--space-md);
} }
.responses { .responses {
font-size: 1rem; font-size: 1.2rem;
padding: var(--space-md); padding-top: var(--space-xl);
} }
.dictionary-section h3 { .verse-container {
padding: var(--space-md) var(--space-lg);
}
.verse-text {
font-size: 1.25rem; font-size: 1.25rem;
} }
.dictionary-section p {
font-size: 0.875rem;
} }
input[type=text] { /* ---- Phones ---- */
max-width: 100%; @media (max-width: 600px) {
} .responses {
font-size: 1.05rem;
padding-top: var(--space-md);
} }
@media (max-width: 480px) { .verse-text {
:root {
--header-height: 120px;
--space-xs: 0.15rem;
--space-sm: 0.35rem;
--space-md: 0.65rem;
--space-lg: 0.85rem;
--space-xl: 1rem;
}
.header a {
font-size: 0.85rem;
padding: var(--space-xs);
}
.dictionary-section h3 {
font-size: 1.1rem; font-size: 1.1rem;
} }
.dictionary-section p { .verse-container {
font-size: 0.8rem; margin: 0.4rem 0;
padding: var(--space-xs) var(--space-md) var(--space-sm);
} }
input[type=button], .site-links a {
input[type=submit], padding: 0.5rem 0.6rem;
input[type=reset], font-size: 0.9rem;
button { }
padding: var(--space-sm) var(--space-md);
.correction-notice,
.variant-notice,
.theological-notice,
.theological-suggestions {
padding: var(--space-md);
}
/* the chapter names drop out, leaving big arrow targets either side of the current chapter */
.chapter-nav a .nav-name {
display: none;
}
.chapter-nav a span[aria-hidden] {
font-size: 2rem;
padding: 0 var(--space-md);
}
}
@media (max-width: 380px) {
.site-links a {
padding: 0.5rem 0.4rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.search-form input[type=submit] {
padding: var(--space-sm) var(--space-md);
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
} }
</style> </style>
+167 -27
View File
@@ -1,47 +1,95 @@
/* Shared Page Styling for Simple Pages */ /* Shared Page Styling for Simple Pages (index, forms, static articles) */
:root { :root {
color-scheme: dark light;
--color-bg-primary: #1a1a1a; --color-bg-primary: #1a1a1a;
--color-bg-secondary: #252525; --color-bg-secondary: #252525;
--color-bg-tertiary: #2f2f2f;
--color-text-primary: #e8e8e8; --color-text-primary: #e8e8e8;
--color-text-secondary: #b8b8b8; --color-text-secondary: #b8b8b8;
--color-text-muted: #888888;
--color-accent-primary: #6b9bd1; --color-accent-primary: #6b9bd1;
--color-accent-hover: #85aede; --color-accent-hover: #85aede;
--color-on-accent: #121212;
--color-link: #6b9bd1; --color-link: #6b9bd1;
--color-link-hover: #85aede; --color-link-hover: #85aede;
--color-border: #3a3a3a; --color-border: #3a3a3a;
--color-shadow: rgba(0, 0, 0, 0.3);
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--border-radius-sm: 4px; --font-scripture: Georgia, "Crimson Text", "Times New Roman", serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--content-max-width: 820px;
--border-radius: 8px; --border-radius: 8px;
--border-radius-sm: 4px;
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
}
@media (prefers-color-scheme: light) {
:root {
--color-bg-primary: #f7f5f0;
--color-bg-secondary: #ffffff;
--color-bg-tertiary: #ece9e1;
--color-text-primary: #1f1f1f;
--color-text-secondary: #4a4a4a;
--color-text-muted: #6b6b6b;
--color-accent-primary: #2b5c94;
--color-accent-hover: #1d4574;
--color-on-accent: #ffffff;
--color-link: #2b5c94;
--color-link-hover: #1d4574;
--color-border: #d6d1c4;
--color-shadow: rgba(0, 0, 0, 0.1);
}
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html, body { [hidden] {
background-color: var(--color-bg-primary); display: none !important;
color: var(--color-text-primary); }
margin: 0;
padding: 1.5rem; html {
font-family: var(--font-ui); -webkit-text-size-adjust: 100%;
line-height: 1.6; text-size-adjust: 100%;
font-size: 16px;
} }
body { body {
max-width: 820px; background-color: var(--color-bg-primary);
color: var(--color-text-primary);
margin: 0 auto; margin: 0 auto;
padding: var(--space-md) max(var(--space-md), env(safe-area-inset-right)) var(--space-2xl) max(var(--space-md), env(safe-area-inset-left));
max-width: calc(var(--content-max-width) + 2 * var(--space-md));
font-family: var(--font-ui);
line-height: 1.6;
font-size: 16px;
overflow-wrap: break-word;
} }
h1, h2, h3, h4 { h1, h2, h3, h4 {
color: var(--color-text-primary); color: var(--color-text-primary);
margin-top: 1.5rem; line-height: 1.25;
margin-bottom: 1rem; margin: 1.5rem 0 1rem;
}
h1 {
font-size: clamp(1.6rem, 5vw + 0.5rem, 2.4rem);
} }
h2 { h2 {
font-size: 2rem; font-size: clamp(1.4rem, 4vw + 0.4rem, 2rem);
border-bottom: 2px solid var(--color-border); border-bottom: 2px solid var(--color-border);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
@@ -49,7 +97,7 @@ h2 {
a { a {
color: var(--color-link); color: var(--color-link);
text-decoration: none; text-decoration: none;
transition: color 150ms ease; transition: color var(--transition-fast);
} }
a:hover { a:hover {
@@ -57,18 +105,41 @@ a:hover {
text-decoration: underline; text-decoration: underline;
} }
:focus-visible {
outline: 2px solid var(--color-accent-primary);
outline-offset: 2px;
}
/* Top navigation: a wrapping row of pill links */
header { header {
margin-bottom: 2rem; margin-left: -0.75rem; /* pill padding lines the text up with the page edge */
padding-bottom: 1rem; display: flex;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-sm);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
header a {
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-weight: 500;
font-size: 0.95rem;
white-space: nowrap;
}
header a:hover {
background-color: var(--color-bg-tertiary);
text-decoration: none;
}
form { form {
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
padding: 1.5rem; padding: var(--space-lg);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
margin: 1.5rem 0; margin: var(--space-lg) 0;
} }
label { label {
@@ -80,15 +151,16 @@ label {
input[type=text] { input[type=text] {
width: 100%; width: 100%;
padding: 0.75rem; min-height: 44px;
padding: 0.6rem 0.75rem;
margin: 0.5rem 0 1rem 0; margin: 0.5rem 0 1rem 0;
font-size: 1rem; font-size: 1rem; /* 16px minimum stops iOS zooming into the field */
font-family: var(--font-ui); font-family: var(--font-ui);
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
transition: border-color 150ms ease; transition: border-color var(--transition-fast);
} }
input[type=text]:focus { input[type=text]:focus {
@@ -96,20 +168,40 @@ input[type=text]:focus {
border-color: var(--color-accent-primary); border-color: var(--color-accent-primary);
} }
/* Radio + text wrapped in a label so the whole pill is the tap target */
.choice {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
margin: 0 var(--space-md) 0 0;
font-weight: 500;
cursor: pointer;
}
input[type=radio] { input[type=radio] {
margin-right: 0.5rem; margin: 0;
width: 1.1rem;
height: 1.1rem;
accent-color: var(--color-accent-primary);
}
.form-row {
margin-bottom: var(--space-md);
} }
input[type=submit], button { input[type=submit], button {
background-color: var(--color-accent-primary); background-color: var(--color-accent-primary);
color: var(--color-bg-primary); color: var(--color-on-accent);
border: none; border: none;
padding: 0.75rem 1.5rem; min-height: 44px;
padding: 0.6rem 1.5rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
font-family: var(--font-ui);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
cursor: pointer; cursor: pointer;
transition: background-color 150ms ease, transform 150ms ease; transition: background-color var(--transition-fast), transform var(--transition-fast);
} }
input[type=submit]:hover, button:hover { input[type=submit]:hover, button:hover {
@@ -120,7 +212,7 @@ input[type=submit]:hover, button:hover {
hr { hr {
border: none; border: none;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
margin: 2rem 0; margin: var(--space-xl) 0;
} }
p { p {
@@ -131,3 +223,51 @@ p {
b, strong { b, strong {
color: var(--color-text-primary); color: var(--color-text-primary);
} }
pre {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: var(--space-md);
overflow-x: auto; /* scroll the code block, never the page */
-webkit-overflow-scrolling: touch;
font-size: 0.85rem;
line-height: 1.5;
max-width: 100%;
}
code {
font-family: var(--font-mono);
}
/* wide tables scroll inside their own box instead of widening the page */
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
border-collapse: collapse;
width: 100%;
}
td, th {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
@media (max-width: 480px) {
form {
padding: var(--space-md);
}
/* full-width submit buttons are easier to hit with a thumb */
input[type=submit] {
width: 100%;
}
pre {
font-size: 0.78rem;
}
}
+110
View File
@@ -0,0 +1,110 @@
"""Fuzzes /find with many ways of typing a reference and checks the verses that come back.
Run from the project root: python3 tools/fuzz_refs.py [sample_size]
Expected verse ids come straight from data/kjv.db, not from the app.
"""
import sys, re, random, sqlite3, itertools, json
sys.path.insert(0, ".") # run from the project root
import logging
import app as a
a.app.testing = True
client = a.app.test_client()
db = sqlite3.connect("data/kjv.db")
BOOKS = [r[0] for r in db.execute("select n from key_english order by b")]
ids = {} # (book, chap) -> [(verse, id)]
for vid, b, c, v in db.execute("select id,b,c,v from fts_kjv"):
ids.setdefault((int(b), int(c)), []).append((int(v), int(vid)))
maxch = {b: max(c for (bb, c) in ids if bb == b) for b in range(1, 67)}
NAME_VARIANTS = {
1: ["Gen", "Ge", "Gn"], 2: ["Exod", "Ex", "Exo"], 3: ["Lev", "Lv"], 4: ["Num", "Nm", "Nu"],
5: ["Deut", "Dt", "Deu"], 6: ["Josh", "Jos"], 7: ["Judg", "Jdg", "Jg"], 8: ["Ru"],
9: ["1 Sam", "1 Sm", "1Sa", "I Samuel", "1st Samuel", "First Samuel"],
10: ["2 Sam", "2 Sm", "2Sa", "II Samuel", "2nd Samuel", "Second Samuel"],
11: ["1 Kgs", "1 Ki", "1Kin", "I Kings", "First Kings"], 12: ["2 Kgs", "2 Ki", "II Kings", "Second Kings"],
13: ["1 Chron", "1 Chr", "1Ch", "I Chronicles", "First Chronicles"], 14: ["2 Chron", "2 Chr", "II Chronicles"],
15: ["Ezr"], 16: ["Neh", "Ne"], 17: ["Est", "Esth"], 18: [], 19: ["Ps", "Psa", "Psalm", "Pslm"],
20: ["Prov", "Pr", "Prv"], 21: ["Eccl", "Ecc", "Eccles", "Ecclesiasts"],
22: ["Song", "Song of Songs", "Songs of Solomon", "Song of Sol", "SOS", "Canticles", "SongofSolomon"],
23: ["Isa", "Is"], 24: ["Jer", "Je"], 25: ["Lam"], 26: ["Ezek", "Eze", "Ezk"], 27: ["Dan", "Dn"],
28: ["Hos", "Ho"], 29: ["Joe", "Jl"], 30: ["Am"], 31: ["Obad", "Ob"], 32: ["Jon", "Jnh"], 33: ["Mic"],
34: ["Nah", "Na"], 35: ["Hab"], 36: ["Zeph", "Zep"], 37: ["Hag", "Hg"], 38: ["Zech", "Zec"], 39: ["Mal"],
40: ["Matt", "Mt", "Mat"], 41: ["Mk", "Mar"], 42: ["Lk", "Luk"], 43: ["Jn", "Joh"], 44: ["Act"],
45: ["Rom", "Ro"], 46: ["1 Cor", "1Co", "I Corinthians", "First Corinthians", "1st Corinthians"],
47: ["2 Cor", "2Co", "II Corinthians", "Second Corinthians"], 48: ["Gal", "Ga"], 49: ["Eph"],
50: ["Phil", "Php", "Philip"], 51: ["Col"], 52: ["1 Thess", "1 Th", "1Thes", "I Thessalonians"],
53: ["2 Thess", "2 Th", "II Thessalonians"], 54: ["1 Tim", "1 Ti", "I Timothy", "First Timothy"],
55: ["2 Tim", "2 Ti", "II Timothy"], 56: ["Tit", "Ti"], 57: ["Phlm", "Phm", "Philem"], 58: ["Heb"],
59: ["Jas", "Jam"], 60: ["1 Pet", "1 Pe", "1Pt", "I Peter", "First Peter"], 61: ["2 Pet", "2 Pe", "II Peter"],
62: ["1 Jn", "1 Jo", "1Joh", "I John", "First John", "1st John"], 63: ["2 Jn", "2 John", "II John"],
64: ["3 Jn", "3 John", "III John"], 65: ["Jud"], 66: ["Rev", "Re", "Revelations", "Revelation of John"],
}
def name_forms(b):
full = BOOKS[b - 1]
forms = {full, full.lower(), full.upper(), full.replace(" ", ""), full.lower().replace(" ", "")}
forms |= set(NAME_VARIANTS.get(b, []))
forms |= {f.lower() for f in NAME_VARIANTS.get(b, [])}
return sorted(forms)
def cases():
rnd = random.Random(1611)
for b in range(1, 67):
for nm in name_forms(b):
ch = rnd.randint(1, maxch[b])
vs = ids[(b, ch)]
v1 = rnd.choice(vs)[0]
v2 = min(vs[-1][0], v1 + rnd.randint(1, 3))
chap_ids = [i for _, i in vs]
single = [i for v, i in vs if v == v1]
rng = [i for v, i in vs if v1 <= v <= v2]
key = a.normalize_book_text(nm)
if key in a.BOOK_CANONICAL or key in a.BARE_BOOK_EXTRA: # abbreviations alone stay keyword searches
for sp in ("", " ", " "):
yield (f"{nm}{sp}", [i for _, i in ids[(b, 1)]], "bare") # book alone -> chapter 1
for fmt, exp, kind in (
(f"{nm} {ch}", chap_ids, "chapter"),
(f"{nm}{ch}", chap_ids, "chapter-nospace"),
(f"{nm} {ch}:{v1}", single, "verse"),
(f"{nm}{ch}:{v1}", single, "verse-nospace"),
(f" {nm} {ch} : {v1} ", single, "verse-spaced"),
(f"{nm} {ch}.{v1}", single, "verse-dot"),
(f"{nm} {ch} {v1}", single, "verse-space-sep"),
(f"{nm} {ch}:{v1}-{v2}", rng, "range"),
(f"{nm} {ch}:{v1} - {v2}", rng, "range-spaced"),
(f"{nm} {ch}:{v1}–{v2}", rng, "range-endash"),
(f"{nm.upper()} {ch}:{v1}", single, "verse-upper"),
(f"{nm} {ch}:{v1}-{v2}\t", rng, "range-trailing-tab"),
):
if v2 > v1 or kind not in ("range", "range-spaced", "range-endash", "range-trailing-tab"):
yield fmt, exp, kind
def verse_ids_in(html):
return [int(x) for x in re.findall(r'data-verse-id="(\d+)"', html)]
def run(limit=None, show=25):
allcases = list(cases())
if limit:
random.Random(7).shuffle(allcases); allcases = allcases[:limit]
fails = {}
total = 0
for q, exp, kind in allcases:
total += 1
try:
r = client.get("/find", query_string={"kw": q, "view": "rich"})
got = verse_ids_in(r.get_data(as_text=True)) if r.status_code == 200 else None
except Exception as e:
got = None
ok = got is not None and got[:len(exp)] == exp and (kind.startswith("range") or kind.startswith("verse") or kind == "chapter" or True) and (len(got) == len(exp) if kind != "bare" else got[:len(exp)] == exp)
if not ok:
fails.setdefault(kind, []).append((q, len(exp), None if got is None else len(got)))
nfail = sum(len(v) for v in fails.values())
print(f"{total} cases, {nfail} failed ({100*nfail//max(total,1)}%)")
for k, v in sorted(fails.items(), key=lambda kv: -len(kv[1])):
print(f" {k}: {len(v)} e.g. {v[:3]}")
return fails
if __name__ == "__main__":
logging.disable(logging.CRITICAL)
run(int(sys.argv[1]) if len(sys.argv) > 1 else None)