Frontend Features and refactor
This commit is contained in:
@@ -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 — next reading →</button>"
|
||||||
|
)
|
||||||
|
controls += f"""{next_html}
|
||||||
|
<button type='submit' name='action' value='back' class='plan-secondary' {back_disabled}>← 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)} – {esc(last_ref)}</div>"
|
||||||
|
f"<div class='plan-meta'>{count} verses · {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 — 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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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">‹</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">›</span></a>
|
||||||
|
</nav>
|
||||||
+10
-10
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
grid-auto-flow: row dense;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.book-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions {
|
.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 & 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>
|
||||||
|
|||||||
+31
-24
@@ -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>
|
||||||
|
</div>
|
||||||
|
<form class="search-form" action="/find" role="search">
|
||||||
|
<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">
|
||||||
<input type="hidden" name="view" value="rich">
|
<input type="hidden" name="view" value="rich">
|
||||||
|
<input type="submit" value="Search">
|
||||||
</form>
|
</form>
|
||||||
<button onclick="make_shortcode()">Get Shortcode: </button>
|
<div class="shortcode">
|
||||||
<a id="context_href" href=''></a>
|
<button type="button" class="btn-small" onclick="make_shortcode()">Get shortcode</button>
|
||||||
<br>
|
<a id="context_href" href=""></a>
|
||||||
←
|
</div>
|
||||||
<a href="{prev_chapter_link}">{prev_chapter_name}</a> |
|
|
||||||
{current_chapter_name} |
|
|
||||||
<a href="{next_chapter_link}">{next_chapter_name}</a>
|
|
||||||
→
|
|
||||||
</div>
|
|
||||||
<script src="js/shortcode.js">
|
|
||||||
make_shortcode();
|
|
||||||
</script>
|
|
||||||
</header>
|
</header>
|
||||||
|
{chapter_nav}
|
||||||
|
<script src="/js/shortcode.js?v=__V__"></script>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"'"}' \
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+515
-121
@@ -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] {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
/* ---- Phones ---- */
|
||||||
:root {
|
@media (max-width: 600px) {
|
||||||
--header-height: 120px;
|
.responses {
|
||||||
--space-xs: 0.15rem;
|
font-size: 1.05rem;
|
||||||
--space-sm: 0.35rem;
|
padding-top: var(--space-md);
|
||||||
--space-md: 0.65rem;
|
|
||||||
--space-lg: 0.85rem;
|
|
||||||
--space-xl: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header a {
|
.verse-text {
|
||||||
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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user