From 9d95ae3ec22d5a41f52a08b0315164acfbb39c8d Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 19 Oct 2026 15:32:06 -0700 Subject: [PATCH] script cache invalidation and verse menu --- app.py | 100 ++++++++++++++++---------- html/reader.html | 2 +- html/saved.html | 57 ++++++++++++++- html/seq_report.html | 2 +- html/shortcode_report.html | 2 +- js/verse_menu.js | 142 +++++++++++++++++++++++++++++++++---- styles/header.css | 70 +++++++++++++++++- 7 files changed, 317 insertions(+), 58 deletions(-) diff --git a/app.py b/app.py index 7a29a2f..a990201 100644 --- a/app.py +++ b/app.py @@ -394,18 +394,39 @@ 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] +# Static assets: each file's URL carries a hash of its own contents ("?v=..."). +# A URL whose hash matches is served as immutable for a year; the hash changes +# whenever the file does, so a deploy is picked up immediately and only the +# files that actually changed get downloaded again. Any other URL for the same +# file (no hash, or an old one) is served with "no-cache", so it is revalidated +# every time instead of lingering in a browser for a year. +ASSET_SOURCES = { + "/styles/page.css": ("styles/page.css", "text/css"), + "/js/collapsible.js": ("js/collapsible.js", "application/javascript"), + "/js/shortcode.js": ("js/shortcode.js", "application/javascript"), + "/js/verse_menu.js": ("js/verse_menu.js", "application/javascript"), + "/js/book_suggest.js": ("js/book_suggest.js", "application/javascript"), +} +ASSET_VERSIONS: dict = { + url: hashlib.md5(open(path, "rb").read()).hexdigest()[:8] + for url, (path, _mime) in ASSET_SOURCES.items() +} + + +def asset_url(url: str) -> str: + return f"{url}?v={ASSET_VERSIONS[url]}" + + +app.jinja_env.globals["asset_url"] = asset_url def load_html(path: str) -> str: - return open(path, "r").read().replace("__V__", ASSET_VERSION) + """Read an html file, filling each '/js/x.js?v=__V__' with that file's own version.""" + return re.sub( + r"(/(?:js|styles)/[\w.\-]+)\?v=__V__", + lambda m: asset_url(m.group(1)), + open(path, "r").read(), + ) cached: dict = { @@ -479,41 +500,42 @@ with app.app_context(): 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") -def serve_shortcode_js(): - response = Response(cached.get("js_shortcode"), mimetype='application/javascript') - response.headers['Cache-Control'] = 'public, max-age=31536000' - return response +def _make_asset_view(url_path: str, mimetype: str): + def view(): + response = Response(cached["assets"][url_path], mimetype=mimetype) + response.add_etag() + if request.args.get("v") == ASSET_VERSIONS[url_path]: + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + response.headers["Cache-Control"] = "no-cache" + return response.make_conditional(request) + view.__name__ = "asset_" + url_path.strip("/").replace("/", "_").replace(".", "_") + return view -@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 +cached["assets"] = { + "/styles/page.css": cached["css_page"], + "/js/collapsible.js": cached["js_collapsible"], + "/js/shortcode.js": cached["js_shortcode"], + "/js/verse_menu.js": cached["js_verse_menu"], + "/js/book_suggest.js": cached["js_book_suggest"], +} +for _url, (_path, _mime) in ASSET_SOURCES.items(): + app.add_url_rule(_url, view_func=_make_asset_view(_url, _mime), methods=["GET"]) -@app.get("/js/collapsible.js") -def serve_collapsible_js(): - response = Response(cached.get("js_collapsible"), mimetype='application/javascript') - response.headers['Cache-Control'] = 'public, max-age=31536000' - return response -@app.get("/styles/page.css") -def serve_page_css(): - response = Response(cached.get("css_page"), mimetype='text/css') - response.headers['Cache-Control'] = 'public, max-age=31536000' +@app.after_request +def html_pages_are_never_stale(response): + """Pages reference versioned assets, so they must always be revalidated to see new versions.""" + if response.mimetype == "text/html" and "Cache-Control" not in response.headers: + response.headers["Cache-Control"] = "no-cache" return response @app.get("/favicon.ico") @app.get("/favicon.svg") def serve_favicon(): response = Response(cached.get("favicon_svg"), mimetype='image/svg+xml') - response.headers['Cache-Control'] = 'public, max-age=31536000' + response.headers['Cache-Control'] = 'public, max-age=86400' return response def static_page(path: str): @@ -529,7 +551,7 @@ def static_html_doc(title: str, body: str): "" "" f"{title} - KJV API" - f"" + f"" "
Home Quick Search
" f"{body}" ) @@ -928,7 +950,7 @@ def parse_query(query_override: str = None): result += "" result += "

1828 Webster Definitions

Click on the word to get the definition.

" result += definitions_html - result += f"" + result += f"" result += "" return result @@ -1687,7 +1709,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"), v=ASSET_VERSION) + return render_template("reader.html", books=cached.get("book_names")) # 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 @@ -1718,7 +1740,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, v=ASSET_VERSION) + return render_template("seq_report.html", uids=uids) @app.get("/screport") # shows current remembered shortcodes def shortcode_report(shortcode=None): @@ -1730,7 +1752,7 @@ def shortcode_report(shortcode=None): shortcodes = short_cur.execute( "SELECT * FROM shortcodes" ).fetchall() - return render_template("shortcode_report.html", shortcodes=shortcodes, v=ASSET_VERSION) + return render_template("shortcode_report.html", shortcodes=shortcodes) def sequential_purge(): diff --git a/html/reader.html b/html/reader.html index c74e560..8831a66 100644 --- a/html/reader.html +++ b/html/reader.html @@ -4,7 +4,7 @@ Books - KJV API - +
diff --git a/html/saved.html b/html/saved.html index 122100b..ae15d95 100644 --- a/html/saved.html +++ b/html/saved.html @@ -14,6 +14,9 @@ .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); } +.item .mynote { white-space: pre-wrap; border-left: 3px solid #d9a93b; padding-left: 0.6rem; color: var(--color-text-secondary); } +.file-btn { display: inline-block; padding: 0.6rem 1.2rem; border: 1px solid var(--color-border); border-radius: var(--border-radius-sm); + background-color: var(--color-bg-tertiary); font-weight: 600; } .empty { color: var(--color-text-muted); } .note { font-size: 0.9rem; color: var(--color-text-muted); } @@ -26,6 +29,8 @@

Saved

Saved in this browser only. Clearing site data or using another device starts fresh.

+

Notes

+

Favorite verses

Definitions

@@ -33,8 +38,17 @@

Cross references

+

Backup

+

Browser storage can be cleared without warning. Download a copy of your notes and saved items, and restore it here or on another device.

+

+ + +

+

+ diff --git a/html/seq_report.html b/html/seq_report.html index 4f3f6dc..f4d8054 100644 --- a/html/seq_report.html +++ b/html/seq_report.html @@ -4,7 +4,7 @@ Sequence Report - KJV API - +
diff --git a/html/shortcode_report.html b/html/shortcode_report.html index a8a3390..372996a 100644 --- a/html/shortcode_report.html +++ b/html/shortcode_report.html @@ -4,7 +4,7 @@ Shortcode Report - KJV API - +
diff --git a/js/verse_menu.js b/js/verse_menu.js index 4a406c0..b4b71fd 100644 --- a/js/verse_menu.js +++ b/js/verse_menu.js @@ -1,8 +1,9 @@ -// 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). +// Every verse card gets an actions button (and the card itself is tappable) +// that opens a small menu: favorite the verse, write a note on it, look up the +// 1828 Webster definition of its words, or see its cross references. +// Everything saved lives in this browser's localStorage (see the /saved page). (function () { - var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs" }; + var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs", notes: "kjv.notes" }; var open = null; // { box, menu } function load(key) { @@ -71,12 +72,72 @@ return "/find?kw=" + encodeURIComponent(ref) + "&view=rich"; } - function markFavorites(root) { + function noteFor(ref) { + var list = load(KEYS.notes); + var at = indexOf(list, function (n) { return n.ref === ref; }); + return at >= 0 ? list[at].note : ""; + } + + // Shows the saved note (if any) under the verse text. + function renderNote(box) { + var old = box.querySelector(".verse-note"); + if (old) { + old.remove(); + } + var info = verseInfo(box); + var note = noteFor(info.ref); + box.classList.toggle("has-note", !!note); + if (!note) { + return; + } + var text = box.querySelector(".verse-text"); + var node = el("div", "verse-note", note); + node.setAttribute("title", "Your note"); + if (text) { + text.after(node); + } else { + box.appendChild(node); + } + } + + function markSaved(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); + renderNote(boxes[i]); + } + } + + // A visible way in to the menu on every verse, so nobody has to guess that the card is tappable. + function addMenuButtons() { + var boxes = document.querySelectorAll(".verse-container"); + for (var i = 0; i < boxes.length; i++) { + var header = boxes[i].querySelector(".verse-reference"); + if (!header || header.querySelector(".vm-more")) { + continue; + } + var button = el("button", "vm-more", "\u22ef"); + button.type = "button"; + button.setAttribute("aria-label", "Verse actions: notes, favorite, define, cross references"); + button.setAttribute("title", "Notes, favorite, define, cross references"); + button.addEventListener("click", (function (box) { + return function () { toggleMenu(box); }; + })(boxes[i])); + header.appendChild(button); + } + var count = document.querySelector(".results-count"); + if (count && boxes.length && !document.querySelector(".verse-hint")) { + count.after(el("div", "verse-hint", "Tap a verse, or its \u22ef button, to add a note, favorite it, define words or see cross references.")); + } + } + + function toggleMenu(box) { + if (open && open.box === box) { + closeMenu(); + } else { + openMenu(box); } } @@ -181,6 +242,49 @@ .catch(function () { panel.textContent = "Could not load cross references."; }); } + // Notes save as you type; clearing the box deletes the note. + function showNote(info, panel, box, onChange) { + panel.textContent = ""; + var area = el("textarea", "vm-note-input"); + area.rows = 4; + area.placeholder = "Your note on " + info.ref + "..."; + area.setAttribute("aria-label", "Note for " + info.ref); + area.value = noteFor(info.ref); + var status = el("div", "vm-hint", "Saved in this browser as you type."); + var timer = null; + function commit() { + var value = area.value.trim(); + var list = load(KEYS.notes); + var at = indexOf(list, function (n) { return n.ref === info.ref; }); + if (!value) { + if (at >= 0) { + list.splice(at, 1); + } + } else if (at >= 0) { + list[at].note = value; + list[at].t = Date.now(); + } else { + list.unshift({ ref: info.ref, text: info.text, note: value, t: Date.now() }); + } + save(KEYS.notes, list); + renderNote(box); + onChange(); + status.textContent = value ? "Saved." : "Note removed."; + } + area.addEventListener("input", function () { + status.textContent = "Saving\u2026"; + clearTimeout(timer); + timer = setTimeout(commit, 400); + }); + area.addEventListener("blur", function () { + clearTimeout(timer); + commit(); + }); + panel.appendChild(area); + panel.appendChild(status); + area.focus(); + } + function openMenu(box) { closeMenu(); var info = verseInfo(box); @@ -206,6 +310,19 @@ paintFav(); }); + var noteBtn = el("button", "vm-btn"); + noteBtn.type = "button"; + function paintNote() { + var has = !!noteFor(info.ref); + noteBtn.textContent = has ? "\u270e Edit note" : "\u270e Note"; + noteBtn.classList.toggle("saved", has); + } + paintNote(); + noteBtn.addEventListener("click", function () { + panel.hidden = false; + showNote(info, panel, box, paintNote); + }); + var define = el("button", "vm-btn", "Define words"); define.type = "button"; define.addEventListener("click", function () { @@ -225,7 +342,7 @@ close.setAttribute("aria-label", "Close menu"); close.addEventListener("click", closeMenu); - [fav, define, xref, close].forEach(function (b) { actions.appendChild(b); }); + [fav, noteBtn, define, xref, close].forEach(function (b) { actions.appendChild(b); }); menu.appendChild(actions); menu.appendChild(panel); box.appendChild(menu); @@ -241,25 +358,22 @@ return; } // links, buttons and the open menu keep their own behaviour; so does selecting text - if (event.target.closest("a, button, input, .verse-menu")) { + if (event.target.closest("a, button, input, textarea, .verse-menu")) { return; } var selection = window.getSelection && window.getSelection().toString(); if (selection) { return; } - if (open && open.box === box) { - closeMenu(); - } else { - openMenu(box); - } + toggleMenu(box); }); document.addEventListener("keydown", function (event) { - if (event.key === "Escape") { + if (event.key === "Escape" && !(event.target && event.target.tagName === "TEXTAREA")) { closeMenu(); } }); - markFavorites(); + markSaved(); + addMenuButtons(); })(); diff --git a/styles/header.css b/styles/header.css index f472bb8..980025c 100644 --- a/styles/header.css +++ b/styles/header.css @@ -456,7 +456,10 @@ button:hover { letter-spacing: 0.5px; color: var(--color-text-secondary); line-height: 1.6; - display: block; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); } .verse-reference a { @@ -610,6 +613,71 @@ button:hover { margin-top: var(--space-xs); } +/* Always-visible entry point to the verse menu, hint line, and notes */ +.vm-more { + flex: 0 0 auto; + min-height: 40px; + min-width: 40px; + margin: -6px -8px -6px 0; + padding: 0; + background: none; + border: none; + color: var(--color-text-muted); + font-size: 1.4rem; + line-height: 1; + letter-spacing: 0; +} + +.vm-more:hover, +.verse-container.selected .vm-more { + background-color: var(--color-bg-primary); + color: var(--color-accent-primary); +} + +.verse-hint { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--color-text-muted); + margin: calc(-1 * var(--space-xs)) 0 var(--space-md); +} + +.verse-note { + margin-top: var(--space-sm); + padding: var(--space-xs) var(--space-md); + font-family: var(--font-ui); + font-size: 0.95rem; + line-height: 1.5; + white-space: pre-wrap; + color: var(--color-text-secondary); + background-color: var(--color-bg-primary); + border-left: 3px solid #d9a93b; + border-radius: var(--border-radius-sm); +} + +.verse-note::before { + content: "\270e "; + color: #d9a93b; +} + +.vm-note-input { + width: 100%; + min-height: 6rem; + padding: var(--space-sm) var(--space-md); + font-family: var(--font-ui); + font-size: 1rem; /* 16px minimum stops iOS zooming into the field */ + line-height: 1.5; + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + border: 2px solid var(--color-border); + border-radius: var(--border-radius-sm); + resize: vertical; +} + +.vm-note-input:focus { + outline: none; + border-color: var(--color-accent-primary); +} + /* ---- Reading plan ---- */ .plan-summary { font-family: var(--font-ui);