feat(booth): per-row link removal + render the link board as real UI

The standing link board is the one MULTI-WRITER booth -- every agent session
appends operator-facing URLs to it. "Delete the folder" was the only
granularity available, so removing one dead link meant hand-editing markdown.
It is 32 rows and only grows.

  booth links                 row number, entry id, raw row
  booth unlink 3              by row number
  booth unlink 8b40e0a5       by entry id (what the UI's x posts)
  POST /b/<name>/unlink       form field `entry` = content id

ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only
and multi-writer: another session can post between listing it and clicking x,
and an index would then delete a neighbour. An id either matches the row you
saw or matches nothing. A row number typed at the CLI is resolved to its id
BEFORE anything is deleted. Appends and prunes now take the same flock on
.links.lock, so a post cannot be lost inside a prune's read-modify-write.

UI: a booth carrying links.md renders as rows -- description, URL, provenance,
copy button, per-row x -- instead of a markdown blob. links.md is filtered out
of the gallery so it does not appear twice; the header counts LINKS not files;
the empty-state and the one-click "Wipe now" both stand down for a board (same
rule as the kept lane: nothing durable is one click from gone).

booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not
require the service venv -- importing app.py drags in FastAPI, so deleting a
line from a text file would have needed a web framework installed.

THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was
correct throughout -- module-only tests would have caught none of them:

- `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1
  whenever the board had rows. `unlink`'s index lookup calls it inside $( )
  under `set -e`, so a successful listing killed the caller and the removal
  silently did nothing while reporting success.
- ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read
  as row numbers, resolved to nothing, and removed nothing. Now disambiguated
  by the id's actual shape, not by "is it numeric".
- filtering links.md out of the gallery left `items` empty, so a full board
  rendered "This booth is empty" and an empty <div class="gallery"> under 32
  visible rows.

87 tests (was 76): parser tolerance of hand-written prose, content-id
stability across concurrent appends, removal precision, UI branch behaviour
for board/normal/empty booths, and subprocess CLI tests pinning the two shell
bugs. Deployed to nh3-dev and verified against the live 32-row board
read-only; board file byte-identical afterwards.
This commit is contained in:
vh
2026-08-23 12:55:33 -07:00
parent d88f235688
commit 22ec06fcaa
7 changed files with 588 additions and 6 deletions
+45 -2
View File
@@ -23,6 +23,8 @@ State is the filesystem — `ls ~/booth-data` tells you everything. That is the
from __future__ import annotations
import asyncio
import fcntl
import hashlib
import io
import os
import re
@@ -34,7 +36,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
@@ -70,6 +72,17 @@ DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not ren
# state anywhere but the filesystem.
KEEP_MARKER = ".forever"
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
link_entry_id,
parse_link_entries,
remove_link_entry,
)
def doc_kind(name: str) -> str | None:
"""'markdown' | 'text' | None — a booth file viewable as a readable page."""
@@ -583,7 +596,21 @@ def create_app(
**base_ctx,
"name": name,
"name_url": quote(name, safe=""),
"items": build_gallery(booth),
# links.md is rendered AS the board below, so it must not also
# appear as a markdown doc tile — that would show the same
# content twice, once interactive and once not.
"items": [
it for it in build_gallery(booth)
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
],
# A booth carrying links.md is the standing link board: render
# its rows as real UI (link, provenance, per-row remove) instead
# of a markdown blob you can only edit by hand. Empty list for
# every other booth, so the template branch simply does not fire.
"board": (
parse_link_entries((booth / LINKS_FILE).read_text())
if (booth / LINKS_FILE).is_file() else []
),
"uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
@@ -693,6 +720,22 @@ def create_app(
# is what deletes. Anything relying on release-then-sweep is relying on a
# 24h delay it probably did not intend.
@app.post("/b/{name}/unlink")
def board_unlink(name: str, entry: str = Form(...)):
"""Remove ONE row from a link board, by content id.
Deliberately not by index: the board is append-only and multi-writer,
so between rendering the page and clicking × another session may have
posted. A content id either matches the row the operator saw or matches
nothing — it can never resolve to a neighbour.
"""
removed = remove_link_entry(resolve_booth(name), entry)
if removed is None:
# Already gone (double-click, stale tab, someone else pruned it).
# Not an error worth a 404 page — the desired end state holds.
pass
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/keep")
def booth_keep(name: str):
(resolve_booth(name) / KEEP_MARKER).touch()