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