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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user