feat(r2): C1 ordinals and C2 the review ring and .seen

- Item.ordinal: the 1-based position in booth_items over the items that
  render. It is appended, and set in the resolver. Tiles print it padded to
  the whole set's width, and a filter never renumbers.
- review_chain: the item order filtered to media. It replaces image_chain as
  the zoom route's ring, so a set of pictures and sound steps through both.
  image_chain stays importable.
- .seen: which media items were looked at full size, written by the review
  route under record_view's gate. It is rewritten whole: deduplicated, pruned
  to live items, sorted. The temp file is created with O_EXCL and swapped in
  with os.replace, so a planted symlink is replaced, never written through.
  It never raises.

Nine new tests. The contiguity and symlink tests are mutation-checked.
669 passed.
This commit is contained in:
vh
2026-09-23 08:32:38 -07:00
parent 7a4d3fcbf8
commit 277554a3f7
4 changed files with 245 additions and 2 deletions
+46 -2
View File
@@ -41,6 +41,7 @@ import os
import re
import secrets
import shutil
import tempfile
import time
import zipfile
from contextlib import asynccontextmanager
@@ -87,6 +88,10 @@ from booth.items import ( # noqa: E402,F401
doc_kind,
find_item,
image_chain,
review_chain,
REVIEW_KINDS,
SEEN_FILE,
read_seen,
read_blurred,
render_doc,
render_doc_body,
@@ -319,6 +324,37 @@ def record_view(booth: Path) -> None:
pass
def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
"""Note that `rel` was looked at full size (R2 C2).
Rewrites the whole marker — the previous set plus `rel`, pruned to rels that
are still items, sorted — so it is deduplicated and never outgrows the
booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created
with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and
`os.replace` swaps a planted `.seen` symlink out rather than writing
through it.
NEVER RAISES, for `record_view`'s reason: not recording a look is a cost
this service can absorb, not answering the request is not.
"""
try:
live = {it.rel for it in items}
seen = (read_seen(booth) | {rel}) & live
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
try:
with os.fdopen(fd, "w") as fh:
fh.write("".join(f"{r}\n" for r in sorted(seen)))
os.replace(tmp, booth / SEEN_FILE)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass
HOLD_UNREADABLE = "unreadable"
HOLD_OPEN = "open"
@@ -522,6 +558,8 @@ def build_gallery(child: Path) -> list[dict]:
"section": it.section,
# U7. Derived in the resolver (INV-1); this only carries it.
"group": it.group,
# R2 C1. Same rule: the resolver numbers, this carries.
"ordinal": it.ordinal,
"caption": it.caption,
"rendered": rendered,
"rendered_html": rendered_html,
@@ -1485,6 +1523,10 @@ def create_app(
# of a thing that is not an item is not a view of the booth.
if item is not None:
record_view(booth)
# R2 C2: WHICH item was looked at — media only, the ring the tape
# draws. Same gate as the view above, and it never raises either.
if item.kind in REVIEW_KINDS:
record_seen(booth, item.rel, items)
marks = marks_for(booth)
item_marks = marks_for_target(marks, f)
common = {
@@ -1505,8 +1547,10 @@ def create_app(
}
if item is not None and item.kind == "image":
# prev/next ring (wraps; only when there is more than one image)
names = image_chain(items)
# prev/next ring (wraps; only when there is more than one item in
# it). R2 C2: the ring is `review_chain` — the item order filtered to
# MEDIA — so a set that mixes pictures and sound steps through both.
names = review_chain(items)
prev_url = next_url = None
if f in names and len(names) > 1:
i = names.index(f)