feat(blur): a booth can be fogged as a whole, composing with per-item blur
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.
This commit is contained in:
@@ -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),
|
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
|
||||||
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one
|
`.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
|
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
|
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 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
|
dotfile is the right shape for new operator state — use it rather than
|
||||||
|
|||||||
+5
-2
@@ -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
|
Dropping back to an alpha is not available: `1.0.0a2` sorts BELOW `1.0.0b1`, and
|
||||||
versions do not go backwards.
|
versions do not go backwards.
|
||||||
|
|
||||||
**Whether `1.0.0` waits for the redesign is the operator's call** and is not
|
🛑 **RULED 2026-09-23: NO `1.0.0` YET.** Verbatim: *"no v1.0 yet."* The tag
|
||||||
yet made. See the design-arc section.
|
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
|
## v1 target
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|||||||
from booth.items import ( # noqa: E402,F401
|
from booth.items import ( # noqa: E402,F401
|
||||||
AUDIO_EXTS,
|
AUDIO_EXTS,
|
||||||
BLUR_FILE,
|
BLUR_FILE,
|
||||||
|
BOOTH_BLUR_FILE,
|
||||||
CAPTION_MAX,
|
CAPTION_MAX,
|
||||||
DOC_MAX_BYTES,
|
DOC_MAX_BYTES,
|
||||||
IMAGE_EXTS,
|
IMAGE_EXTS,
|
||||||
@@ -96,6 +97,7 @@ from booth.items import ( # noqa: E402,F401
|
|||||||
REVIEW_KINDS,
|
REVIEW_KINDS,
|
||||||
SEEN_FILE,
|
SEEN_FILE,
|
||||||
read_seen,
|
read_seen,
|
||||||
|
is_booth_blurred,
|
||||||
read_blurred,
|
read_blurred,
|
||||||
render_doc,
|
render_doc,
|
||||||
render_doc_body,
|
render_doc_body,
|
||||||
@@ -123,6 +125,30 @@ VIEW_MARKER = ".viewed"
|
|||||||
# Anyone who reads this marker as protection has misread it.
|
# 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]:
|
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
|
"""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
|
mid-write cannot leave a half-file that read_blurred would parse as a
|
||||||
@@ -2079,6 +2105,22 @@ def create_app(
|
|||||||
record_view(booth)
|
record_view(booth)
|
||||||
return RedirectResponse(url=_safe_next(next), status_code=303)
|
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")
|
@app.post("/b/{name}/blur")
|
||||||
def booth_blur(name: str, f: str = Form(...), on: str = Form("1")):
|
def booth_blur(name: str, f: str = Form(...), on: str = Form("1")):
|
||||||
"""Toggle one item's blur. Reversible and cosmetic, so no confirmation.
|
"""Toggle one item's blur. Reversible and cosmetic, so no confirmation.
|
||||||
|
|||||||
+43
-2
@@ -46,6 +46,16 @@ DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not ren
|
|||||||
|
|
||||||
BLUR_FILE = ".blurred"
|
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:
|
def classify(name: str) -> str:
|
||||||
"""image | video | audio | other, by extension."""
|
"""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()}
|
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:
|
def _section_of(rel: str) -> str | None:
|
||||||
"""The item's parent directory relative to the booth; None at the root.
|
"""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)
|
caption, sidecars = _resolve_captions(by_rel)
|
||||||
blurred = read_blurred(booth) # ONE read per call, not one per item
|
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] = []
|
items: list[Item] = []
|
||||||
for rel in sorted(by_rel):
|
for rel in sorted(by_rel):
|
||||||
@@ -342,15 +378,20 @@ def booth_items(booth: Path) -> list[Item]:
|
|||||||
size = p.stat().st_size
|
size = p.stat().st_size
|
||||||
except OSError:
|
except OSError:
|
||||||
size = 0
|
size = 0
|
||||||
|
kind = classify(p.name)
|
||||||
items.append(
|
items.append(
|
||||||
Item(
|
Item(
|
||||||
rel=rel,
|
rel=rel,
|
||||||
url=quote(rel, safe="/"),
|
url=quote(rel, safe="/"),
|
||||||
kind=classify(p.name),
|
kind=kind,
|
||||||
section=_section_of(rel),
|
section=_section_of(rel),
|
||||||
group=_group_of(rel),
|
group=_group_of(rel),
|
||||||
caption=caption.get(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),
|
doc=doc_kind(p.name),
|
||||||
size=size,
|
size=size,
|
||||||
# Counted over items that RENDER: a caption sidecar or a name
|
# Counted over items that RENDER: a caption sidecar or a name
|
||||||
|
|||||||
@@ -212,3 +212,25 @@ old = '''
|
|||||||
var d = shown(btn.getAttribute('data-desc') || '');'''
|
var d = shown(btn.getAttribute('data-desc') || '');'''
|
||||||
new = '''
|
new = '''
|
||||||
var d = btn.getAttribute('data-desc') || '';'''
|
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'''
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# both arguments must go through it, not just one
|
||||||
assert "shown(btn.getAttribute('data-desc')" in html
|
assert "shown(btn.getAttribute('data-desc')" in html
|
||||||
assert "shown(btn.getAttribute('data-url')" 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user