From c1108a19661f469f421b910118987fd913b6163d Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 17:06:37 -0700 Subject: [PATCH] feat(blur): a booth can be fogged as a whole, composing with per-item blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator ruled booth-level blur in and chose reading A for the reveal ("A is fine"). design-dev specced the semantics and owns the controls; this is the storage half. COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is in .blurred, so turning booth blur off leaves an agent's per-item choice exactly as the poster left it. An override would need a per-item "unblurred" exception list, which is state nobody can see. Resolved in booth_items, so every surface inherits it for free — Desk strip, tiles, flag tray, filmstrip, stage all already read Item.blurred and none of them learns the booth flag exists (INV-1). Images and video only; audio has nothing to hide from a glance. A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must round-trip exactly; a boolean has nothing to round-trip, and matching `.forever` means the two whole-booth flags read the same way. We told design-dev it would be JSON and it should not be — said so rather than quietly shipping the other thing. is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the inversion is the point: is_kept fails toward keeping because a failed read must not authorise a delete; this fails toward HIDING, because a failed read must not reveal something a poster asked to fog. Both are "the failure does not cause the loss". Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds .blurbooth to CLAUDE.md's dotfile list. 766 green. --- CLAUDE.md | 3 +- ROADMAP.md | 7 ++- booth/app.py | 42 +++++++++++++++++ booth/items.py | 45 +++++++++++++++++- tests/mutations/u7_navigation.toml | 22 +++++++++ tests/test_booth.py | 76 ++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1ba629e..4dfe361 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,8 @@ No database. `ls ~/booth-data` tells you everything the service knows. Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep), `.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one rel per line — ⚠ see below), `.seen` (R2: rels looked at full size, a JSON -ARRAY), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin +ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not +JSON, because a boolean has no rels to round-trip), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new dotfile costs nothing in item counts, galleries or zips. That skip is why the dotfile is the right shape for new operator state — use it rather than diff --git a/ROADMAP.md b/ROADMAP.md index d2dc179..f4c9267 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,8 +13,11 @@ claim about now — and no further pre-release is cut until the arc lands. Dropping back to an alpha is not available: `1.0.0a2` sorts BELOW `1.0.0b1`, and versions do not go backwards. -**Whether `1.0.0` waits for the redesign is the operator's call** and is not -yet made. See the design-arc section. +🛑 **RULED 2026-09-23: NO `1.0.0` YET.** Verbatim: *"no v1.0 yet."* The tag +stays at `1.0.0b1`, no further pre-release is cut until the arc lands, and the +arc now includes the flow redesign, compare mode and the Desk revisions still in +flight. Do not cut a release because the suite is green and the roadmap looks +complete — it has looked complete twice already. ## v1 target diff --git a/booth/app.py b/booth/app.py index d955169..c9bf859 100644 --- a/booth/app.py +++ b/booth/app.py @@ -80,6 +80,7 @@ TEMPLATES_DIR = Path(__file__).parent / "templates" from booth.items import ( # noqa: E402,F401 AUDIO_EXTS, BLUR_FILE, + BOOTH_BLUR_FILE, CAPTION_MAX, DOC_MAX_BYTES, IMAGE_EXTS, @@ -96,6 +97,7 @@ from booth.items import ( # noqa: E402,F401 REVIEW_KINDS, SEEN_FILE, read_seen, + is_booth_blurred, read_blurred, render_doc, render_doc_body, @@ -123,6 +125,30 @@ VIEW_MARKER = ".viewed" # Anyone who reads this marker as protection has misread it. +def set_booth_blurred(booth: Path, on: bool) -> bool: + """Fog or unfog a whole booth. Returns the state it is now in. + + A marker, created and removed rather than written — so there is no window in + which the file exists holding a half-written "off", which is the whole + reason `.forever` is a marker too. + + Never raises on the remove path: unfogging something already unfogged is the + state the caller asked for, exactly as unflagging an unflagged item is.""" + marker = booth / BOOTH_BLUR_FILE + if on: + marker.touch(exist_ok=True) + return True + try: + marker.unlink() + except FileNotFoundError: + pass + except OSError: + # Cannot remove it, so it is still there and the booth is still blurred. + # Saying "off" here would be a lie the next render contradicts. + return is_booth_blurred(booth) + return False + + def set_blurred(booth: Path, rel: str, on: bool) -> set[str]: """Add or remove one item from the blur set. Atomic replace, so a crash mid-write cannot leave a half-file that read_blurred would parse as a @@ -2079,6 +2105,22 @@ def create_app( record_view(booth) return RedirectResponse(url=_safe_next(next), status_code=303) + @app.post("/b/{name}/blurbooth") + def booth_blur_all(name: str, on: str = Form("1"), back: str = Form("")): + """Toggle blur for the WHOLE booth — the operator's header control, and + what an agent sets at post time by dropping the marker in the folder. + + COMPOSES with per-item blur and never overrides it: turning this off + leaves `.blurred` exactly as the poster left it. Reversible and + cosmetic, so no confirmation — and, like per-item blur, it hides from a + glance and does not protect anything.""" + booth = resolve_booth(name) + set_booth_blurred(booth, on not in ("0", "false", "")) + landing = f"/b/{quote(name, safe='')}/" + if back: + landing += f"view?f={quote(back, safe='/')}" + return RedirectResponse(url=landing, status_code=303) + @app.post("/b/{name}/blur") def booth_blur(name: str, f: str = Form(...), on: str = Form("1")): """Toggle one item's blur. Reversible and cosmetic, so no confirmation. diff --git a/booth/items.py b/booth/items.py index 4ce35d5..4794b80 100644 --- a/booth/items.py +++ b/booth/items.py @@ -46,6 +46,16 @@ DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not ren BLUR_FILE = ".blurred" +# Booth-level blur: the whole booth is fogged, agent-set at post time or +# toggled by the operator. A MARKER, deliberately not JSON like `.seen` — +# `.seen` is JSON because it holds rels that must round-trip exactly, and a +# boolean has nothing to round-trip. It matches `.forever`, which is the other +# whole-booth flag, so the two read the same way. +BOOTH_BLUR_FILE = ".blurbooth" + +# What booth-level blur applies to. Audio has nothing to hide from a glance. +BLURRABLE_KINDS = {"image", "video"} + def classify(name: str) -> str: """image | video | audio | other, by extension.""" @@ -166,6 +176,31 @@ def read_blurred(booth: Path) -> set[str]: return {ln.strip() for ln in text.splitlines() if ln.strip()} +def is_booth_blurred(booth: Path) -> bool: + """Whether the WHOLE booth is blurred. + + `lstat`, not `exists()`, and an unreadable answer counts as BLURRED — + the same shape as `is_kept` with the safety inverted, and the inversion is + the point. `is_kept` fails toward keeping because a failed read must not + authorize a delete; this fails toward HIDING, because a failed read must not + reveal something the poster asked to fog. Both directions are "the failure + does not cause the loss". + + A SYMLINK counts, dangling or not: somebody put it there to mean blur. + + Composes with `.blurred`, never overrides it — turning booth blur off must + not erase an agent's per-item choice, and an override would need a per-item + "unblurred" exception list, which is state nobody can see. + """ + try: + (booth / BOOTH_BLUR_FILE).lstat() + return True + except FileNotFoundError: + return False + except OSError: + return True # cannot tell -> fog it; see above + + def _section_of(rel: str) -> str | None: """The item's parent directory relative to the booth; None at the root. @@ -332,6 +367,7 @@ def booth_items(booth: Path) -> list[Item]: caption, sidecars = _resolve_captions(by_rel) blurred = read_blurred(booth) # ONE read per call, not one per item + booth_blur = is_booth_blurred(booth) # likewise: one stat, not one per item items: list[Item] = [] for rel in sorted(by_rel): @@ -342,15 +378,20 @@ def booth_items(booth: Path) -> list[Item]: size = p.stat().st_size except OSError: size = 0 + kind = classify(p.name) items.append( Item( rel=rel, url=quote(rel, safe="/"), - kind=classify(p.name), + kind=kind, section=_section_of(rel), group=_group_of(rel), caption=caption.get(rel), - blurred=rel in blurred, + # Booth blur COMPOSES with the per-item set. Resolved HERE so + # every surface inherits it for free — Desk strip, tiles, tray, + # filmstrip, stage all already read `Item.blurred` and none of + # them learns about the booth flag (INV-1). + blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS), doc=doc_kind(p.name), size=size, # Counted over items that RENDER: a caption sidecar or a name diff --git a/tests/mutations/u7_navigation.toml b/tests/mutations/u7_navigation.toml index 1e1c6c7..05f5593 100644 --- a/tests/mutations/u7_navigation.toml +++ b/tests/mutations/u7_navigation.toml @@ -212,3 +212,25 @@ old = ''' var d = shown(btn.getAttribute('data-desc') || '');''' new = ''' var d = btn.getAttribute('data-desc') || '';''' + +[[mutation]] +label = "booth blur OVERRIDES per-item instead of composing" +file = "booth/items.py" +test = "tests/test_booth.py::test_booth_blur_composes_with_per_item_and_never_overrides_it" +old = ''' +blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS),''' +new = ''' +blurred=(booth_blur and kind in BLURRABLE_KINDS),''' + + +[[mutation]] +label = "an unreadable booth-blur marker reveals instead of fogging" +file = "booth/items.py" +test = "tests/test_booth.py::test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals" +old = ''' + except OSError: + return True # cannot tell -> fog it; see above''' +new = ''' + except OSError: + return False # cannot tell -> reveal it''' + diff --git a/tests/test_booth.py b/tests/test_booth.py index c481e9a..81d9b59 100644 --- a/tests/test_booth.py +++ b/tests/test_booth.py @@ -1650,3 +1650,79 @@ def test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row(tmp_path): # both arguments must go through it, not just one assert "shown(btn.getAttribute('data-desc')" in html assert "shown(btn.getAttribute('data-url')" in html + + +def test_booth_blur_composes_with_per_item_and_never_overrides_it(tmp_path): + """The operator ruled booth-level blur in; design-dev specced the semantics + and this is the half that is ours. + + COMPOSES, never overrides: an item is blurred iff the booth is blurred OR it + is in `.blurred`. Turning booth blur off must leave an agent's per-item + choice exactly as the poster left it — an override would need a per-item + "unblurred" exception list, which is state nobody can see. + + Defeating change: assigning `Item.blurred` from the booth flag instead of + OR-ing it.""" + from booth.app import set_blurred, set_booth_blurred + from booth.items import booth_items + + b = tmp_path / "g" + b.mkdir() + for n in ("a.png", "b.png", "c.mp3"): + (b / n).write_bytes(b"x") + set_blurred(b, "b.png", True) + + def state(): + return {i.rel: i.blurred for i in booth_items(b)} + + assert state() == {"a.png": False, "b.png": True, "c.mp3": False} + + set_booth_blurred(b, True) + # audio has nothing to hide from a glance + assert state() == {"a.png": True, "b.png": True, "c.mp3": False} + + set_booth_blurred(b, False) + assert state() == {"a.png": False, "b.png": True, "c.mp3": False}, \ + "unfogging the booth erased the poster's per-item blur" + + +def test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals(tmp_path, monkeypatch): + """`is_kept` fails toward KEEPING because a failed read must not authorise a + delete. This fails toward HIDING, because a failed read must not reveal + something the poster asked to fog. Same shape, inverted safety, and the + inversion is the point. + + Defeating change: `except OSError: return False`.""" + import booth.items as items_mod + + b = tmp_path / "g" + b.mkdir() + + real = pathlib.Path.lstat + + def boom(self, *a, **k): + if self.name == items_mod.BOOTH_BLUR_FILE: + raise PermissionError(13, "nope") + return real(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "lstat", boom) + assert items_mod.is_booth_blurred(b) is True + + +def test_the_blurbooth_route_toggles_and_lands_back(tmp_path): + """The POST target design-dev's header control needs, with `back=view` so + fogging from the review does not eject you from the review.""" + b = tmp_path / "g" + b.mkdir() + (b / "a.png").write_bytes(b"x") + c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) + + r = c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False) + assert r.status_code == 303 and r.headers["location"] == "/b/g/" + assert (b / ".blurbooth").exists() + + r = c.post("/b/g/blurbooth", data={"on": "1", "back": "a.png"}, follow_redirects=False) + assert r.headers["location"] == "/b/g/view?f=a.png" + + c.post("/b/g/blurbooth", data={"on": "0"}, follow_redirects=False) + assert not (b / ".blurbooth").exists()