From 0136842258295068afa470a922c301a157cb1887 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 28 Sep 2026 18:13:42 +0200 Subject: [PATCH] fix: asset URLs carry a content-based build id so edited scripts are never served stale Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + routes/admin.py | 32 ++++++++++++++++++++++++----- tests/test_asset_versions.py | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 tests/test_asset_versions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9928e..685c54c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **The page thumbnail is the Review button.** In the thumbnail grid, clicking the (big) page image opens the page in review; the separate *Review* / *Review this page* buttons are gone. A page shared by two or more articles keeps one labelled *Review* button per article, so it stays clear which is meant. ### Fixed +- **Changed scripts and stylesheets now reach the browser immediately.** They were requested under a version that only moved at release time (`…js?v=1.53.4`) and cached as immutable for a year, so a browser or proxy could keep running old code after an update (a button added to a script simply did not appear). The app shell now requests every asset under `-`, where the build id follows the newest script/stylesheet/manifest change. - **An approved page is no longer purple.** A page the quality check flagged stayed purple even after you approved its article; now your approval wins and the page shows its review colour (the warning badge on the card still lists what the check found). Purple means flagged *and* nobody has approved anything on the page. An old MP3 on an article nobody approved (left over from before pages were linked) no longer turns a page green either: audio only counts for approved articles. - **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply. - **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides. diff --git a/routes/admin.py b/routes/admin.py index 2f8a7f8..c0c368c 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -7,7 +7,7 @@ import re from pathlib import Path from fastapi import APIRouter, Body, HTTPException -from fastapi.responses import FileResponse, PlainTextResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response from core.constants import STATIC_DIR, __version__ @@ -78,12 +78,34 @@ def _mount_relation(path_str: str, mounts: frozenset[str]) -> tuple[bool, bool]: return is_persisted, leads_to +_ASSET_URL_RE = re.compile(r"(/static/[^\"'\s?]+)\?v=[^\"'\s&]+") + + +def asset_build_id() -> str: + """A version string that changes whenever any script, stylesheet or module list changes. + + Scripts and stylesheets are requested as ``file?v=`` and cached for a year as + immutable, so a version that only moves at release time leaves a browser (or a proxy in front + of it) running old code after an edit -- a button added to a script simply does not appear. + The newest modification time under static/ js, css and the manifest is folded into the version. + """ + newest = 0 + for pattern in ("js/*.js", "css/*.css", "*.css", "loader.js", "nav.js", "module-manifest.json"): + for path in STATIC_DIR.glob(pattern): + try: + newest = max(newest, int(path.stat().st_mtime)) + except OSError: + pass + return f"{__version__}-{newest:x}" + + @router.get("/") async def index(): - return FileResponse( - STATIC_DIR / "index.html", - headers={"Cache-Control": "no-store, max-age=0"}, - ) + """The app shell, with every /static asset URL carrying the current build id.""" + html = (STATIC_DIR / "index.html").read_text("utf-8") + build = asset_build_id() + return HTMLResponse(_ASSET_URL_RE.sub(lambda m: f"{m.group(1)}?v={build}", html), + headers={"Cache-Control": "no-store, max-age=0"}) @router.get("/favicon.ico") diff --git a/tests/test_asset_versions.py b/tests/test_asset_versions.py new file mode 100644 index 0000000..2288899 --- /dev/null +++ b/tests/test_asset_versions.py @@ -0,0 +1,39 @@ +"""The app shell must request scripts and stylesheets under a version that follows their content.""" +import os +import tempfile +import time +import unittest +from pathlib import Path + +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-assetv-")) + + +class AssetBuildIdTests(unittest.TestCase): + def test_the_index_rewrites_every_static_asset_version(self): + import asyncio + from routes.admin import asset_build_id, index + response = asyncio.run(index()) + html = response.body.decode("utf-8") + build = asset_build_id() + self.assertIn(f"/static/style.css?v={build}", html) + self.assertIn(f"/static/loader.js?v={build}", html) + self.assertNotIn("?v=1.53.4", html) + self.assertEqual(response.headers["cache-control"], "no-store, max-age=0") + + def test_the_build_id_changes_when_a_script_changes_and_not_otherwise(self): + from core.constants import STATIC_DIR + from routes.admin import asset_build_id + first = asset_build_id() + self.assertEqual(first, asset_build_id()) + probe = Path(STATIC_DIR) / "js" / "magazine.js" + original = probe.stat().st_mtime + try: + os.utime(probe, (original + 1_000_000, original + 1_000_000)) + self.assertNotEqual(first, asset_build_id()) + finally: + os.utime(probe, (original, original)) + self.assertEqual(first, asset_build_id()) + + +if __name__ == "__main__": + unittest.main()