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 4be880f36c
commit 0ad332bb4a
7 changed files with 588 additions and 6 deletions
+62 -1
View File
@@ -12,6 +12,8 @@
# booth keep <name> exempt a booth from the 24h sweep, forever
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
@@ -45,7 +47,7 @@ KEEP=".forever" # must match KEEP_MARKER in b
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
usage() {
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]}" >&2
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>}" >&2
exit 2
}
@@ -110,9 +112,68 @@ case "$cmd" in
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
# atomic on POSIX, so concurrent sessions cannot interleave a line — which
# matters here precisely because many agents post to one board.
# flock on the same sidecar the Python remover uses. The append is
# atomic by itself, but `unlink` does read-modify-write, and without a
# shared lock this line could land inside that window and be rewritten
# away by the prune.
touch -- "$board/.links.lock"
flock "$board/.links.lock" \
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
echo "$URL/b/$LINKS_BOARD/"
;;
links)
board="$DATA/$LINKS_BOARD/links.md"
[ -f "$board" ] || { echo "no link board yet"; exit 0; }
# The id is the same content hash the web UI and `unlink` use, so a row can
# be named unambiguously even while other sessions are appending to the board.
n=0
while IFS= read -r line; do
case "$line" in "- ["*) ;; *) continue ;; esac
n=$((n+1))
id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
printf '%3d %s %s\n' "$n" "$id" "$line"
done < "$board"
# `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
# idiom returns 1 whenever the board is non-empty, so `booth links` exits
# non-zero on success — and `unlink`'s index lookup, which calls it inside
# $( ) under `set -e`, then dies silently.
if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
;;
unlink)
[ $# -ge 1 ] || usage
board="$DATA/$LINKS_BOARD"
[ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
target="$1"
# A bare number is accepted for convenience but resolved to the row's
# CONTENT ID before anything is deleted: between `booth links` and
# `booth unlink` another session may have appended, and deleting by POSITION
# would then take the wrong row. An id either matches the row you saw or
# matches nothing.
# DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
# hex chars, and roughly one id in forty is all digits — those were being
# read as row numbers and silently resolving to nothing. Match the id's
# actual shape first; anything else numeric is an index.
case "$target" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
;; # already a content id
''|*[!0-9]*)
echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
*)
target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
[ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
esac
# `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
# a command substitution elsewhere in this script has bitten us already.
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
*) usage ;;
esac