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