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. - **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 ### 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. - **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. - **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. - **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 pathlib import Path
from fastapi import APIRouter, Body, HTTPException 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__ 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 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("/") @router.get("/")
async def index(): async def index():
return FileResponse( """The app shell, with every /static asset URL carrying the current build id."""
STATIC_DIR / "index.html", html = (STATIC_DIR / "index.html").read_text("utf-8")
headers={"Cache-Control": "no-store, max-age=0"}, 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") @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()