feat(r2): C4 the Desk — the index triaged by what needs the operator
- list_booths gains open_since (parsed, never compared as text), flags, landed_at (content only; a new, differently named clock, INV-5), viewed_at, and a four-image preview that keeps blur. - The index renders needs you / new since you looked / everything else, always in that order. Needs you includes unreadable marks, so a damaged judgment file cannot hide. Everything else keeps list_booths' order rather than stating a second rule. An empty section renders nothing. - The side column holds live benches (a damaged registry says so), bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8), and the pickup form. - test_booth's kept-lane test is rewritten as the contract declared: kept is a fact on each row, not a lane. Two of the new tests were VACUOUS on their first draft, and mutation- checking caught both. The clocks test used a future t0, so a hand-set marker outranked every real write. The look-then-judge test followed the flag's 303, and the resulting GET recorded a fresh look. Both are fixed and now go red under their mutation.
This commit is contained in:
+111
-7
@@ -41,11 +41,13 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float:
|
||||
return newest
|
||||
|
||||
|
||||
def _content_mtime(path: Path) -> float:
|
||||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
||||
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||||
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
|
||||
content, so none of them may make a booth read as newly landed.
|
||||
|
||||
Files only, never directories: creating `.viewed` bumps the booth
|
||||
directory's own mtime, and counting that would make the first look at a
|
||||
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
|
||||
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
|
||||
booth we cannot read is shown as new rather than hidden as old.
|
||||
"""
|
||||
newest = 0.0
|
||||
try:
|
||||
for p in path.rglob("*"):
|
||||
rel = p.relative_to(path)
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
return time.time()
|
||||
return newest
|
||||
|
||||
|
||||
def _viewed_at(path: Path) -> float | None:
|
||||
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
|
||||
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
|
||||
marker it claims to be, never followed."""
|
||||
try:
|
||||
return os.lstat(path / VIEW_MARKER).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _stamp(created: str) -> datetime | None:
|
||||
"""A mark's `created` as an aware datetime, or None when it will not parse.
|
||||
Strings are never compared: two ISO stamps with different offsets sort
|
||||
wrong as text. A naive stamp is read as UTC."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(created)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def booth_age_seconds(path: Path, now: float | None = None) -> float:
|
||||
now = time.time() if now is None else now
|
||||
return now - _newest_mtime(path)
|
||||
@@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# The Desk shows this many bookmarks and links to the board for the rest.
|
||||
BOOKMARKS_SHOWN = 8
|
||||
|
||||
HOLD_UNREADABLE = "unreadable"
|
||||
HOLD_OPEN = "open"
|
||||
|
||||
@@ -529,6 +584,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# rather than re-opening .blurred here.
|
||||
thumb_blurred = it.blurred
|
||||
mtime = _newest_mtime(child)
|
||||
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
|
||||
# stamps are left out, so a booth whose every open pick is unparseable
|
||||
# has no `open_since` and sorts after every booth that has one.
|
||||
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
|
||||
booths.append(
|
||||
{
|
||||
"name": child.name,
|
||||
@@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
# ---- R2 C4, the Desk. All from the pass above; no second read.
|
||||
"open_since": min(stamps) if stamps else None,
|
||||
"flags": sum(1 for m in marks if m.shape == "flag"),
|
||||
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||||
# `landed_at` is content. "New since you looked" reads only the
|
||||
# second, so a flag or a view never makes a booth look new.
|
||||
"landed_at": _content_mtime(child),
|
||||
"viewed_at": _viewed_at(child),
|
||||
# The first four images in item order, as the originals shown
|
||||
# small. Blurred ones stay blurred, the cover's rule.
|
||||
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
|
||||
}
|
||||
)
|
||||
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
|
||||
@@ -779,6 +849,7 @@ def create_app(
|
||||
sweep_interval_s: int = 900,
|
||||
max_upload_mb: float = 1024.0,
|
||||
max_files: int = 50,
|
||||
links_board: str = "links",
|
||||
) -> FastAPI:
|
||||
data_dir = Path(data_dir).expanduser().resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -889,19 +960,49 @@ def create_app(
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
# Two lanes, split here rather than in the template: kept boards are a
|
||||
# different KIND of thing from the ephemeral churn — durable, deliberate,
|
||||
# operator-facing — and burying them in a feed that turns over daily is
|
||||
# exactly how they would get lost, which is the problem they exist to
|
||||
# solve. Kept renders first.
|
||||
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
|
||||
|
||||
Three sections, ALWAYS in this order, each booth in exactly one:
|
||||
needs — an open pick, or marks that cannot be read (somebody has to
|
||||
fix those, so they must not hide further down). Oldest open
|
||||
question first; a booth with no parseable stamp after every
|
||||
booth that has one; name breaks ties.
|
||||
new — content landed since the booth was last looked at, or never
|
||||
looked at. Newest content first; name breaks ties.
|
||||
rest — everything else, in `list_booths`' own order (last activity
|
||||
first, name as the tie-break). No second rule is stated.
|
||||
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
|
||||
the lanes sorted nothing. Kept status still shows on every row.
|
||||
"""
|
||||
everything = list_booths(data_dir, ttl_seconds)
|
||||
needs = [b for b in everything
|
||||
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
|
||||
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
|
||||
if b["open_since"] else (1, 0.0), b["name"]))
|
||||
in_needs = {b["name"] for b in needs}
|
||||
new = [b for b in everything if b["name"] not in in_needs
|
||||
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
|
||||
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
|
||||
in_new = {b["name"] for b in new}
|
||||
rest = [b for b in everything
|
||||
if b["name"] not in in_needs and b["name"] not in in_new]
|
||||
benches, benches_error = read_benches(data_dir)
|
||||
board = data_dir / links_board
|
||||
bookmarks = [row for row in _board_rows(board)
|
||||
if booth_target(row["url"]) is None] if board.is_dir() else []
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"kept": [b for b in everything if b["kept"]],
|
||||
"booths": [b for b in everything if not b["kept"]],
|
||||
"needs": needs,
|
||||
"new": new,
|
||||
"rest": rest,
|
||||
"benches": [b for b in benches if b.state != "retired"],
|
||||
"benches_error": benches_error,
|
||||
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
|
||||
"bookmarks_total": len(bookmarks),
|
||||
"board_url": f"/b/{quote(links_board, safe='')}/",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1875,6 +1976,9 @@ def _from_env() -> FastAPI:
|
||||
sweep_interval_s=interval,
|
||||
max_upload_mb=max_mb,
|
||||
max_files=max_n,
|
||||
# The board the CLI's `booth link` writes (scripts/booth reads the same
|
||||
# variable), so the Desk's bookmarks come from where they are written.
|
||||
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,87 @@
|
||||
box-shadow:var(--shadow-sm)}
|
||||
.thumb .badge+.badge-mark{top:36px}
|
||||
|
||||
/* ---- THE DESK (R2 C4) -------------------------------------------------
|
||||
The index as triage. Sections stack in a fixed order; a row is a booth.
|
||||
"Needs you" rows carry an amber inner edge — the one thing on the page
|
||||
that asks to be looked at. */
|
||||
.desk{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--space-6);align-items:start}
|
||||
@media (max-width:1000px){.desk{grid-template-columns:1fr}}
|
||||
.desk-sec{margin:0 0 var(--space-6)}
|
||||
.desk-head{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-family:var(--font-mono);
|
||||
font-size:var(--size-micro);font-weight:500;letter-spacing:var(--tracking-caps);text-transform:uppercase;
|
||||
color:var(--text-muted)}
|
||||
.desk-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
|
||||
.desk-head-needs{color:var(--warning-text)}
|
||||
.desk-head-new{color:var(--intel-text)}
|
||||
.desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)}
|
||||
.desk-row{display:grid;grid-template-columns:210px minmax(0,1fr) auto;gap:var(--space-4);align-items:center;
|
||||
padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl);
|
||||
background:var(--surface-card);box-shadow:var(--shadow-sm);
|
||||
transition:border-color var(--dur-2) var(--ease-out)}
|
||||
.desk-row:hover,.desk-row:focus-within{border-color:var(--border-strong)}
|
||||
.desk-row.is-needs{box-shadow:inset 3px 0 0 var(--warning),var(--shadow-sm)}
|
||||
.desk-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:3px;height:58px;border-radius:var(--radius-md);
|
||||
overflow:hidden;background:var(--surface-sunken)}
|
||||
.desk-strip img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.desk-strip .ph{grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);
|
||||
font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
|
||||
.desk-strip:hover{text-decoration:none}
|
||||
.desk-main{min-width:0}
|
||||
.desk-title{display:block;font-weight:600;font-size:var(--size-h3);line-height:1.3;color:var(--text-heading);
|
||||
overflow-wrap:anywhere}
|
||||
.desk-title:hover{color:var(--text-link);text-decoration:none}
|
||||
.desk-slug{font-family:var(--font-mono);font-size:var(--size-caption);font-weight:400;color:var(--text-muted);margin-left:6px}
|
||||
.desk-main .prov{margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.desk-facts{margin-top:5px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
|
||||
.desk-flags{color:var(--success-text)}
|
||||
.desk-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||
.badge-new{color:var(--intel-text);background:var(--intel-soft)}
|
||||
.badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)}
|
||||
/* The row's keep / release / × — quiet until the row is hovered or focused,
|
||||
because a destructive control must not compete with the thing you came to
|
||||
read. Static here: on the old cards they floated over a thumbnail. */
|
||||
.desk-acts{display:flex;gap:6px;opacity:0;transition:opacity var(--dur-1)}
|
||||
.desk-row:hover .desk-acts,.desk-acts:focus-within{opacity:1}
|
||||
.desk-acts form{position:static;opacity:1;margin:0}
|
||||
.desk-acts button{height:28px;min-width:28px;padding:0 8px;font-size:var(--size-sm);border-radius:var(--radius-md);
|
||||
border:1px solid var(--border-strong);background:var(--surface-raised);color:var(--text-body);
|
||||
-webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer}
|
||||
.desk-acts .release button{font-family:var(--font-mono);font-size:var(--size-caption)}
|
||||
.desk-acts .wipe button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
|
||||
.desk-acts .keepit button:hover,.desk-acts .release button:hover{background:var(--surface-overlay)}
|
||||
@media (max-width:700px){
|
||||
.desk-row{grid-template-columns:1fr}
|
||||
.desk-side{flex-direction:row;align-items:center;justify-content:space-between}
|
||||
.desk-acts{opacity:1}
|
||||
}
|
||||
/* the side column */
|
||||
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
|
||||
overflow:hidden;margin-bottom:var(--space-4);box-shadow:var(--shadow-sm)}
|
||||
.desk-panel-head{display:flex;align-items:baseline;gap:8px;margin:0;padding:10px 14px;background:var(--surface-raised);
|
||||
border-bottom:1px solid var(--border-subtle);font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
|
||||
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-heading)}
|
||||
.desk-panel-head .desk-rule{margin-left:auto}
|
||||
.desk-bench,.desk-mark{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-subtle);
|
||||
font-size:var(--size-sm);color:var(--text-heading)}
|
||||
.desk-bench:last-child,.desk-mark:last-of-type{border-bottom:0}
|
||||
.desk-bench:hover,.desk-mark:hover{background:var(--surface-sunken);text-decoration:none}
|
||||
.desk-mark{display:block;color:var(--text-link)}
|
||||
.desk-mark.is-pinned{background:var(--surface-overlay)}
|
||||
.desk-bench-dot{flex:0 0 auto;width:6px;height:6px;border-radius:var(--radius-pill);background:var(--text-muted)}
|
||||
/* Device 3: a live bench is a running thing — the one row that glows. */
|
||||
.desk-bench.is-live .desk-bench-dot{background:var(--accent);box-shadow:var(--glow-armed)}
|
||||
.desk-bench.is-promoted .desk-bench-dot{background:var(--intel)}
|
||||
.desk-bench-main{display:flex;flex-direction:column;min-width:0}
|
||||
.desk-bench-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.desk-bench-sub{display:block;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)}
|
||||
.desk-more{display:block;padding:9px 14px;border-top:1px solid var(--border-subtle);font-family:var(--font-mono);
|
||||
font-size:var(--size-caption)}
|
||||
.desk-panel .uploader{flex-direction:column;margin:0;padding:12px}
|
||||
.desk-panel .drop{flex:1 1 auto;padding:14px 12px}
|
||||
.desk-panel .up-go{padding:10px 14px;border-radius:var(--radius-lg)}
|
||||
.desk-panel .drop.has+.up-go{padding:10px 14px;border-radius:var(--radius-lg)}
|
||||
|
||||
/* ---- index: lanes and cards ------------------------------------------ */
|
||||
.lane-head{margin:28px 0 12px;font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
|
||||
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted);
|
||||
|
||||
+153
-142
@@ -1,136 +1,155 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{% block content %}
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<span class="drop-main">Upload files for pickup</span>
|
||||
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
|
||||
<input id="booth-files" name="files" type="file" multiple>
|
||||
</label>
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
|
||||
then new since you looked, then everything else — always in that order, and
|
||||
the ORDER WITHIN each is decided in app.index, never here. A section with no
|
||||
booths renders nothing at all: no heading, no empty box (the negative half
|
||||
of the kept-lane pair this replaces). #}
|
||||
|
||||
{% if kept %}
|
||||
{# Kept boards render FIRST and look different on purpose: they are durable
|
||||
operator-facing things (the agent link board, standing reports) and the
|
||||
point of the lane is that they cannot be lost in a feed that turns over
|
||||
every day. No countdown — they have no expiry to advertise. #}
|
||||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
||||
<div class="grid kept-grid">
|
||||
{% for b in kept %}
|
||||
<article class="card card-kept">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
{# A cover blurred inside the booth must be blurred here too, or the
|
||||
front page undoes the censoring the booth page applied. #}
|
||||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||||
{% elif b.has_index %}
|
||||
<div class="ph">▦ page</div>
|
||||
{% elif b.kinds.video %}
|
||||
<div class="ph">▶ video</div>
|
||||
{% elif b.kinds.audio %}
|
||||
<div class="ph">♪ audio</div>
|
||||
{% else %}
|
||||
<div class="ph">◆ files</div>
|
||||
{% endif %}
|
||||
<span class="badge badge-kept">★ kept</span>
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
</div>
|
||||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||||
release-then-find-it-in-the-other-lane, on the theory that two
|
||||
deliberate acts protect durable boards. In practice it protects
|
||||
nothing and costs a hunt: the board you just released is loose in a
|
||||
feed that turns over, and you have to go find it to finish the job
|
||||
you had already decided on.
|
||||
|
||||
The protection now lives in the CONFIRMATION, not in the number of
|
||||
lanes you must traverse — this one names the booth and says the word
|
||||
KEPT, where the ephemeral × just asks. A deliberate act, one click,
|
||||
reachable.
|
||||
|
||||
Release still exists and is still the reversible option. Note it
|
||||
BUMPS the directory mtime, so the board's age resets and it survives
|
||||
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
|
||||
which is exactly why a direct × was worth adding. #}
|
||||
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
|
||||
is the later sibling, so it painted over the × completely: measured
|
||||
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
|
||||
centre returned the release form. The × was unclickable from the day
|
||||
it shipped.
|
||||
|
||||
One flex row, positioned once, instead of two independently guessed
|
||||
offsets — so neither control can drift back on top of the other when
|
||||
a label changes width. #}
|
||||
<div class="kept-actions">
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
data-booth="{{ b.name }}" data-confirm="release">
|
||||
<button title="release this board so it can be wiped">release</button>
|
||||
</form>
|
||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe-kept">
|
||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{# The first four images, the originals shown small. A blurred one stays
|
||||
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
|
||||
kind placeholder the cards used to. #}
|
||||
{% macro preview(b) -%}
|
||||
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
|
||||
{% if b.preview %}
|
||||
{% for url, blurred in b.preview %}
|
||||
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not booths %}
|
||||
{% if not kept %}
|
||||
<div class="empty">
|
||||
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||||
</div>
|
||||
{% elif b.has_index %}<span class="ph">▦ page</span>
|
||||
{% elif b.kinds.video %}<span class="ph">▶ video</span>
|
||||
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
|
||||
{% else %}<span class="ph">◆ files</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="grid">
|
||||
{% for b in booths %}
|
||||
<article class="card">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||||
{% elif b.has_index %}
|
||||
<div class="ph">▦ page</div>
|
||||
{% elif b.kinds.video %}
|
||||
<div class="ph">▶ video</div>
|
||||
{% elif b.kinds.audio %}
|
||||
<div class="ph">♪ audio</div>
|
||||
{% else %}
|
||||
<div class="ph">◆ files</div>
|
||||
{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro row(b, section) -%}
|
||||
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
|
||||
{{ preview(b) }}
|
||||
<div class="desk-main">
|
||||
{# The manifest title leads when there is one; the directory name stays
|
||||
beside it because it is what the URL says. #}
|
||||
<a class="desk-title" href="/b/{{ b.name_url }}/">
|
||||
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
|
||||
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
|
||||
{%- else -%}{{ b.name }}{%- endif -%}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
{{ provenance(b.manifest) }}
|
||||
<div class="desk-facts">
|
||||
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
||||
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
|
||||
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
|
||||
</div>
|
||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||
kept booth, never keep an ephemeral one, so the round trip was only
|
||||
closed if you had a shell. Reversible, so no confirmation — the × next
|
||||
to it is the destructive one and keeps its prompt. #}
|
||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe">
|
||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="desk-side">
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
|
||||
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
|
||||
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
<div class="desk-acts">
|
||||
{# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
|
||||
booth name travels as a data attribute and never reaches a JS string
|
||||
(see the script below). Release is reversible, so it has no prompt of
|
||||
its own beyond the wording. #}
|
||||
{% if b.kept %}
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
data-booth="{{ b.name }}" data-confirm="release">
|
||||
<button title="release this booth so it can be wiped">release</button>
|
||||
</form>
|
||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe-kept">
|
||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe">
|
||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="desk">
|
||||
<div class="desk-list">
|
||||
{% if needs %}
|
||||
<section class="desk-sec" data-section="needs">
|
||||
<h2 class="desk-head desk-head-needs">Needs you <span class="desk-rule">oldest question first</span></h2>
|
||||
{% for b in needs %}{{ row(b, 'needs') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if new %}
|
||||
<section class="desk-sec" data-section="new">
|
||||
<h2 class="desk-head desk-head-new">New since you looked <span class="desk-rule">newest first</span></h2>
|
||||
{% for b in new %}{{ row(b, 'new') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if rest %}
|
||||
<section class="desk-sec" data-section="rest">
|
||||
<h2 class="desk-head">Everything else <span class="desk-rule">last activity first</span></h2>
|
||||
{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if not needs and not new and not rest %}
|
||||
<div class="empty">
|
||||
No booths yet. Drop a folder into <code>{{ data_dir }}</code>, or upload files for pickup.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<aside class="desk-aside">
|
||||
{# Benches: running things. DAMAGED AND ABSENT MUST NOT RENDER THE SAME —
|
||||
an unreadable registry says so; an empty one renders no panel. #}
|
||||
{% if benches_error %}
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches</h2>
|
||||
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
|
||||
</section>
|
||||
{% elif benches %}
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
|
||||
{% for b in benches %}
|
||||
<a class="desk-bench is-{{ b.state }}" href="{{ b.url }}" target="_blank" rel="noopener">
|
||||
<span class="desk-bench-dot" aria-hidden="true"></span>
|
||||
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
|
||||
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if bookmarks %}
|
||||
<section class="desk-panel" data-panel="bookmarks">
|
||||
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
|
||||
{% for e in bookmarks %}
|
||||
<a class="desk-mark{% if e.pinned %} is-pinned{% endif %}" href="{{ e.url }}" target="_blank" rel="noopener">
|
||||
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</a>
|
||||
{% endfor %}
|
||||
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="desk-panel" data-panel="pickup">
|
||||
<h2 class="desk-panel-head">Pickup</h2>
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<span class="drop-main">Upload files for pickup</span>
|
||||
<span class="drop-sub" id="drop-sub">drop here, or click · wiped in {{ ttl_hours }}h</span>
|
||||
<input id="booth-files" name="files" type="file" multiple>
|
||||
</label>
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||||
@@ -162,31 +181,23 @@
|
||||
});
|
||||
})();
|
||||
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
|
||||
These were an inline onsubmit calling confirm() with the booth NAME
|
||||
interpolated straight into the JS string literal. Jinja's autoescape is
|
||||
HTML-attribute escaping, not JS-string escaping: the browser decodes the
|
||||
entity back to a quote before the JS parser ever sees it, so a booth name
|
||||
crafted to close that string executed on submit. Booth names are
|
||||
agent-authored — making a folder under the data dir is the whole API — so
|
||||
that is a live path, not a theoretical one.
|
||||
|
||||
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
|
||||
never reaches a JS string literal. Same pattern the board controls already
|
||||
use. With JS off the form submits without a prompt, which is what every
|
||||
no-JS browser here already did. */
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
||||
travels as a data attribute, where escaping is escaping, and never reaches
|
||||
a JS string literal: a booth name is agent-authored, and an inline handler
|
||||
carrying one was a live injection path. With JS off the form submits
|
||||
without a prompt. */
|
||||
(function () {
|
||||
var WORDS = {
|
||||
release: function (n) {
|
||||
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
|
||||
+ 'can wipe it from there. Nothing is deleted by this step.';
|
||||
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
||||
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
|
||||
},
|
||||
'wipe-kept': function (n) {
|
||||
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
|
||||
return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
|
||||
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
|
||||
+ 'else is going to do this for you \u2014 and nothing brings it back.';
|
||||
+ 'else is going to do this for you — and nothing brings it back.';
|
||||
},
|
||||
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
|
||||
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
|
||||
};
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||
|
||||
@@ -31,7 +31,7 @@ touches:
|
||||
- "booth/templates/base.html (layout CSS; the in-place script)"
|
||||
- "booth/templates/doc.html (NOT restructured — a doc keeps its reading page; named because it was checked)"
|
||||
- "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)"
|
||||
- "tests/test_booth.py (TWO assertions change: L785-786, the kept-lane presence pair. L810-811, the absence pair, survive unchanged. See 'Assertions that change')"
|
||||
- "tests/test_booth.py (THREE assertions change, all in test_index_separates_kept_from_ephemeral: L785-786, the kept-lane presence pair, and L789, kept-before-ephemeral. L810-811, the absence pair, survive unchanged. See 'Assertions that change')"
|
||||
- "tests/test_flow.py (NEW)"
|
||||
assumptions:
|
||||
- "ONE VIEWER. `.seen` records what has been seen at full size, not WHO saw it. ROADMAP parks 'per-viewer state (who has seen what)' on the one-viewer premise; this contract keeps that premise and does not reopen the parked item."
|
||||
@@ -220,8 +220,10 @@ rule — a second renderer in JavaScript would be the same bug in a new language
|
||||
have no `open_since` and sort after every booth that has one.
|
||||
2. **New since you looked** — `not in_needs_you and (viewed_at is None or
|
||||
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
|
||||
3. **Everything else** — ordered by `(-mtime, name)`, where `mtime` is today's
|
||||
`_newest_mtime`: last activity first.
|
||||
3. **Everything else** — in `list_booths`' own existing order: `(mtime, name)`
|
||||
descending, where `mtime` is today's `_newest_mtime`. That is last activity
|
||||
first, with name as the tie-break (`test_the_index_order_has_a_tie_breaker`
|
||||
pins it). The Desk reuses that rule rather than stating a second one.
|
||||
- Flagging or viewing a booth moves it up this section. That is intended:
|
||||
it is activity. It never moves the booth into (2), because (2) reads
|
||||
`landed_at` (INV-5).
|
||||
@@ -346,7 +348,7 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
| Desk sections | fixed: needs → new → everything |
|
||||
| needs you | `(open_since, name)` |
|
||||
| new since you looked | `(-landed_at, name)` |
|
||||
| everything else | `(-mtime, name)` |
|
||||
| everything else | `list_booths` order: `(mtime, name)` descending |
|
||||
| bookmarks | `order_for_display` |
|
||||
|
||||
The notes list keeps `(created, id)`.
|
||||
@@ -387,6 +389,7 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
|---|---|---|---|
|
||||
| test_booth.py L785 | `class="grid kept-grid"` present when a booth is kept | absent; the kept booth appears in its Desk section with the `kept` lifetime line | requirement 8: the lanes sort nothing |
|
||||
| test_booth.py L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same |
|
||||
| test_booth.py L789 | the kept booth renders BEFORE the ephemeral one (`html.index("links") < html.index("scratch")`) | replaced by the Desk's stated order (needs → new → everything, each with its own key) | the kept-first order was the lane's; with no lane there is no kept-first rule, and a second hidden ordering would break INV-2 |
|
||||
| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — |
|
||||
|
||||
Every other existing assertion is expected to survive, and one of the TDD
|
||||
|
||||
+10
-9
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
|
||||
assert booth["count"] == 1
|
||||
|
||||
|
||||
def test_index_separates_kept_from_ephemeral(client):
|
||||
def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
|
||||
"""R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
|
||||
This test used to require a kept LANE rendered before the ephemeral grid.
|
||||
The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
|
||||
nothing — and orders by what needs the operator instead (tested in
|
||||
tests/test_flow.py). What survives is the fact: a kept booth still says it
|
||||
is kept, on its own row."""
|
||||
c, data = client
|
||||
_touch(data / "scratch" / "a.png")
|
||||
_touch(data / "links" / "a.png")
|
||||
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
|
||||
|
||||
html = c.get("/").text
|
||||
|
||||
# Assert on the lane's markup, not on the word "Kept" — that string also
|
||||
# appears in the stylesheet comment that is served on every page, so a bare
|
||||
# substring check passes for the wrong reason.
|
||||
assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
|
||||
assert 'class="card card-kept"' in html
|
||||
# The kept lane is rendered before the ephemeral grid, so the operator sees
|
||||
# durable boards first rather than hunting for them among the churn.
|
||||
assert html.index("links") < html.index("scratch")
|
||||
assert 'data-booth="links" data-kept="1"' in html
|
||||
assert 'data-booth="scratch" data-kept="0"' in html
|
||||
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
|
||||
|
||||
|
||||
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -224,3 +225,185 @@ def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landin
|
||||
r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == landing
|
||||
|
||||
|
||||
# ---- C4: the Desk -------------------------------------------------------------
|
||||
|
||||
def _at(path: pathlib.Path, t: float) -> None:
|
||||
import os
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _desk(body: str) -> dict[str, list[str]]:
|
||||
"""section -> the booths it renders, in render order."""
|
||||
out = {}
|
||||
for sec, inner in re.findall(r'<section class="desk-sec"[^>]*data-section="(\w+)"[^>]*>(.*?)</section>',
|
||||
body, re.S):
|
||||
out[sec] = re.findall(r'<article class="desk-row[^"]*" data-booth="([^"]+)"', inner)
|
||||
return out
|
||||
|
||||
|
||||
def test_the_desk_triages_needs_you_then_new_then_everything_else(tmp_path):
|
||||
"""The tracer for C4: three sections, always in this order, each booth in
|
||||
exactly one of them."""
|
||||
from booth.marks import declare_pick
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
asks = _booth(tmp_path, "asks", {"a.png": PNG})
|
||||
declare_pick(asks, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
_booth(tmp_path, "fresh", {"a.png": PNG}) # never looked at
|
||||
seen = _booth(tmp_path, "seen", {"a.png": PNG})
|
||||
_at(seen / "a.png", t0)
|
||||
(seen / ".viewed").write_bytes(b"")
|
||||
_at(seen / ".viewed", t0 + 60) # looked AFTER it landed
|
||||
body = _client(tmp_path).get("/").text
|
||||
assert _desk(body) == {"needs": ["asks"], "new": ["fresh"], "rest": ["seen"]}
|
||||
assert body.index('data-section="needs"') < body.index('data-section="new"') \
|
||||
< body.index('data-section="rest"')
|
||||
|
||||
|
||||
def _set_created(booth: pathlib.Path, mark_id: str, created: str) -> None:
|
||||
import json
|
||||
doc = json.loads((booth / ".marks.json").read_text())
|
||||
for m in doc["marks"]:
|
||||
if m["id"] == mark_id:
|
||||
m["created"] = created
|
||||
(booth / ".marks.json").write_text(json.dumps(doc))
|
||||
|
||||
|
||||
def test_needs_you_orders_by_the_parsed_stamp_not_the_string(tmp_path):
|
||||
"""`created` is a string. As text, 11:00-07:00 sorts before 12:30-05:00;
|
||||
as time it is 18:00Z against 17:30Z, so the second question is OLDER and
|
||||
leads. An unparseable stamp, and a booth whose marks cannot be read, sort
|
||||
after every parseable one; name breaks the tie."""
|
||||
from booth.marks import declare_pick
|
||||
for n in ("alpha", "bravo", "charlie", "delta"):
|
||||
b = _booth(tmp_path, n, {"a.png": PNG})
|
||||
if n != "delta":
|
||||
declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
|
||||
_set_created(tmp_path / "alpha", "q", "2026-09-22T11:00:00-07:00")
|
||||
_set_created(tmp_path / "bravo", "q", "2026-09-22T12:30:00-05:00")
|
||||
_set_created(tmp_path / "charlie", "q", "last tuesday")
|
||||
(tmp_path / "delta" / ".marks.json").write_text("{not json")
|
||||
body = _client(tmp_path).get("/").text
|
||||
assert _desk(body)["needs"] == ["bravo", "alpha", "charlie", "delta"]
|
||||
assert "marks unreadable" in body
|
||||
|
||||
|
||||
def test_flags_and_notes_alone_do_not_make_a_booth_need_you(tmp_path):
|
||||
"""Needs-you means a question TO the operator. Flags and notes are the
|
||||
operator's own judgment."""
|
||||
from booth.marks import write_note
|
||||
b = _booth(tmp_path, "judged", {"a.png": PNG})
|
||||
set_flag(b, "a.png", True)
|
||||
write_note(b, None, "done here")
|
||||
assert "needs" not in _desk(_client(tmp_path).get("/").text)
|
||||
|
||||
|
||||
def test_new_since_you_looked_reads_content_not_activity(tmp_path):
|
||||
"""INV-5, the two clocks. A flag made after the last look is ACTIVITY and
|
||||
must not make a booth look new; a file landed after the last look is
|
||||
CONTENT and must. Newest content first."""
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
judged = _booth(tmp_path, "judged", {"a.png": PNG})
|
||||
delivered = _booth(tmp_path, "delivered", {"a.png": PNG})
|
||||
later = _booth(tmp_path, "later", {"a.png": PNG})
|
||||
for b in (judged, delivered, later):
|
||||
_at(b / "a.png", t0)
|
||||
(b / ".viewed").write_bytes(b"")
|
||||
_at(b / ".viewed", t0 + 10)
|
||||
set_flag(judged, "a.png", True) # activity after the look
|
||||
(delivered / "b.png").write_bytes(PNG)
|
||||
_at(delivered / "b.png", t0 + 20) # content after the look
|
||||
(later / "b.png").write_bytes(PNG)
|
||||
_at(later / "b.png", t0 + 30) # ...and later still
|
||||
desk = _desk(_client(tmp_path).get("/").text)
|
||||
assert desk["new"] == ["later", "delivered"]
|
||||
assert desk["rest"] == ["judged"]
|
||||
|
||||
|
||||
def test_an_empty_section_renders_nothing_and_a_full_one_renders(tmp_path):
|
||||
"""The negative half of the kept-lane pair, carried forward: a section with
|
||||
no booths has no heading and no box. Checked against the element, never a
|
||||
bare word the stylesheet also contains."""
|
||||
c = _client(tmp_path)
|
||||
body = c.get("/").text
|
||||
for sec in ("needs", "new", "rest"):
|
||||
assert f'data-section="{sec}"' not in body
|
||||
assert 'data-panel="benches"' not in body and 'data-panel="bookmarks"' not in body
|
||||
_booth(tmp_path, "fresh", {"a.png": PNG})
|
||||
body = c.get("/").text
|
||||
assert 'data-section="new"' in body
|
||||
assert 'data-section="needs"' not in body and 'data-section="rest"' not in body
|
||||
|
||||
|
||||
def test_a_look_then_a_judgment_leaves_the_booth_out_of_new(tmp_path):
|
||||
"""Through the routes, not hand-set markers. Every Booth write that CREATES
|
||||
a dotfile — `.viewed`, the marks file's temp-and-replace — bumps the booth
|
||||
DIRECTORY's mtime. A `landed_at` that read the directory would make the
|
||||
flag you set after looking read as a fresh delivery."""
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
_at(b / "a.png", t0)
|
||||
_at(b, t0)
|
||||
c = _client(tmp_path)
|
||||
assert _desk(c.get("/").text) == {"new": ["g"]}
|
||||
c.get("/b/g/")
|
||||
time.sleep(0.02)
|
||||
# Not following the 303: following it GETs the booth page, which records a
|
||||
# fresh look and would hide the defect this pins. A session writing a mark
|
||||
# from the CLI never looks at the page at all.
|
||||
c.post("/b/g/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False)
|
||||
assert _desk(c.get("/").text) == {"rest": ["g"]}
|
||||
|
||||
|
||||
def _link(desc: str, url: str, who: str = "x-dev") -> str:
|
||||
return f"- [{desc}]({url}) <sub>· {who} · 2026-09-01 10:00</sub>\n"
|
||||
|
||||
|
||||
def test_the_side_column_shows_live_benches_and_non_booth_bookmarks(tmp_path):
|
||||
"""Benches: non-retired, registry order. Bookmarks: the board the CLI
|
||||
writes, booth URLs left out (a booth announces itself on the Desk), pinned
|
||||
first then newest, capped at eight with the way to the rest."""
|
||||
from booth.benches import set_bench_state, upsert_bench
|
||||
upsert_bench(tmp_path, "http://h:1/", "live one", "a-dev")
|
||||
retired, _ = upsert_bench(tmp_path, "http://h:2/", "old one", "a-dev")
|
||||
set_bench_state(tmp_path, retired.id, "retired")
|
||||
rows = "".join(_link(f"ref {n}", f"http://ref/{n}") for n in range(10))
|
||||
rows += _link("a booth", "http://10.0.0.1:8090/b/somebooth/")
|
||||
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
|
||||
(board / ".forever").write_bytes(b"")
|
||||
body = _client(tmp_path).get("/").text
|
||||
benches = re.search(r'data-panel="benches".*?</section>', body, re.S).group(0)
|
||||
assert "live one" in benches and "old one" not in benches
|
||||
marks = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
|
||||
shown = re.findall(r'class="desk-mark[^"]*" href="([^"]+)"', marks)
|
||||
assert shown == [f"http://ref/{n}" for n in (9, 8, 7, 6, 5, 4, 3, 2)] # newest first, 8
|
||||
assert "all 10 on the board" in marks
|
||||
|
||||
|
||||
def test_a_damaged_bench_registry_says_so_rather_than_rendering_empty(tmp_path):
|
||||
(tmp_path / ".benches.json").write_text("{broken")
|
||||
body = _client(tmp_path).get("/").text
|
||||
panel = re.search(r'data-panel="benches".*?</section>', body, re.S)
|
||||
assert panel and "could not be read" in panel.group(0)
|
||||
|
||||
|
||||
def test_a_row_previews_four_images_keeps_blur_and_counts_flags(tmp_path):
|
||||
"""The originals shown small (no generated thumbnail), the first four in
|
||||
item order, a blurred one still blurred. The flag count is on the row."""
|
||||
from booth.app import set_blurred
|
||||
b = _booth(tmp_path, "g", {f"{n}.png": PNG for n in "abcde"})
|
||||
set_blurred(b, "b.png", True)
|
||||
set_flag(b, "c.png", True)
|
||||
set_flag(b, "e.png", True)
|
||||
body = _client(tmp_path).get("/").text
|
||||
row = re.search(r'<article class="desk-row[^"]*" data-booth="g".*?</article>', body, re.S).group(0)
|
||||
imgs = re.findall(r'<img class="([^"]*)" loading="lazy" src="/b/g/([^"]+)"', row)
|
||||
assert imgs == [("", "a.png"), ("blurred-thumb", "b.png"), ("", "c.png"), ("", "d.png")]
|
||||
assert "2 flagged" in row
|
||||
|
||||
|
||||
def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
|
||||
_booth(tmp_path, "songs", {"a.mp3": b"ID3", "b.mp3": b"ID3"})
|
||||
row = re.search(r'data-booth="songs".*?</article>', _client(tmp_path).get("/").text, re.S).group(0)
|
||||
assert "♪ audio" in row and "<img" not in row
|
||||
|
||||
Reference in New Issue
Block a user