fix: asset URLs carry a content-based build id so edited scripts are never served stale

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 18:13:42 +02:00
parent 3b5e20a4c8
commit 0136842258
3 changed files with 67 additions and 5 deletions

View File

@ -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 `<version>-<build id>`, 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.

View File

@ -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=<version>`` 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")

View File

@ -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()