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:
+46
-2
@@ -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)
|
||||
|
||||
@@ -95,6 +95,27 @@ class Item:
|
||||
blurred: bool
|
||||
doc: str | None
|
||||
size: int
|
||||
# R2 C1: the 1-based position in `booth_items` order over ALL items — the
|
||||
# number the operator means by "the third one". Set in the resolver loop
|
||||
# and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field
|
||||
# is a positional-construction break.
|
||||
ordinal: int
|
||||
|
||||
|
||||
# R2 C2: which items have been looked at full size. UI state, not judgment —
|
||||
# never exposed to sessions, holds nothing. One viewer: this records WHAT was
|
||||
# seen, never who saw it.
|
||||
SEEN_FILE = ".seen"
|
||||
|
||||
|
||||
def read_seen(booth: Path) -> set[str]:
|
||||
"""Rels seen at full size. Missing or unreadable file -> empty set; a
|
||||
damaged marker costs the tape its memory, never the page."""
|
||||
try:
|
||||
text = (booth / SEEN_FILE).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return set()
|
||||
return {ln.strip() for ln in text.splitlines() if ln.strip()}
|
||||
|
||||
|
||||
def read_blurred(booth: Path) -> set[str]:
|
||||
@@ -273,6 +294,10 @@ def booth_items(booth: Path) -> list[Item]:
|
||||
blurred=rel in blurred,
|
||||
doc=doc_kind(p.name),
|
||||
size=size,
|
||||
# Counted over items that RENDER: a caption sidecar or a name
|
||||
# the quote() guard skipped takes no number, so the numbers
|
||||
# stay contiguous over what the operator can see.
|
||||
ordinal=len(items) + 1,
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -287,6 +312,18 @@ def image_chain(items: Sequence[Item]) -> list[str]:
|
||||
return [it.rel for it in items if it.kind == "image"]
|
||||
|
||||
|
||||
# R2 C2: what the review route steps through. ONE LINE: the item order
|
||||
# filtered to media. It is a declared change to the zoom-ring rule, which was
|
||||
# images only: a listening set is reviewed the same way a picture set is.
|
||||
REVIEW_KINDS = ("image", "video", "audio")
|
||||
|
||||
|
||||
def review_chain(items: Sequence[Item]) -> list[str]:
|
||||
"""The rels of the media items, in item order — the review's prev/next ring,
|
||||
its filmstrip and its tape."""
|
||||
return [it.rel for it in items if it.kind in REVIEW_KINDS]
|
||||
|
||||
|
||||
def find_item(items: Sequence[Item], rel: str) -> Item | None:
|
||||
"""The record for one rel, or None — the zoom/doc route's entry point."""
|
||||
for it in items:
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
</details>
|
||||
{%- endmacro %}
|
||||
|
||||
{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
|
||||
a column of them lines up. Width reads `all_items`, never the filtered list:
|
||||
a filter must not change how a number is written any more than which. #}
|
||||
{% macro ordinal(it) -%}
|
||||
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block title %}{{ name }} · The Booth{% endblock %}
|
||||
{% block content %}
|
||||
<div class="boothhead">
|
||||
@@ -310,6 +317,7 @@
|
||||
<details class="doc-inline" open>
|
||||
<summary class="doc-bar">
|
||||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||||
{{ ordinal(it) }}
|
||||
<span class="doc-name">{{ it.name }}</span>
|
||||
<span class="doc-spacer"></span>
|
||||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||||
@@ -333,6 +341,7 @@
|
||||
</figure>
|
||||
{% else %}
|
||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}">
|
||||
{{ ordinal(it) }}
|
||||
{% if it.blurred %}
|
||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||||
|
||||
Reference in New Issue
Block a user