feat(booth): asks — a multiple-choice question a session poses in a booth, answered by the operator as a radio form + notes, written back as an answer sidecar

- booth/asks.py (stdlib): <stem>.ask.json question / <stem>.answer.json answer; normalise+validate, atomic write, list with answer folded in, broken asks surfaced not hidden
- POST /b/<name>/answer: validates choice against the ask (400), unknown stem 404, re-answer overwrites
- booth.html asks panel above the gallery; amber open / green answered; JS-off form POST; index card + booth header badge for open asks
- CLI: booth ask / asks / answer [--wait [SECS]]; remote sessions poll <stem>.answer.json over HTTP
- ask/answer files excluded from gallery items and item counts; 23 tests; v0.1.9
This commit is contained in:
vh
2026-09-09 07:24:16 -07:00
parent 5de5583762
commit 3fe01225a9
9 changed files with 721 additions and 6 deletions
+77 -1
View File
@@ -15,6 +15,22 @@
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
#
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
# pose a multiple-choice question in a booth
# booth asks <name> list a booth's asks and whether each is answered
# booth answer <name> <stem> [--wait [SECS]]
# print the answer JSON (exit 1 if unanswered);
# --wait polls until it lands (default 3600 s)
#
# ASKS. A session needs the operator to pick one of N things — which render,
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
# a booth; the page renders it as a radio form with a notes field; submitting
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
# Remote sessions: rsync the ask in, then poll
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
#
# 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
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
@@ -47,7 +63,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]|links|unlink <id|index>}" >&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>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
exit 2
}
@@ -175,5 +191,65 @@ if removed is None:
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
ask)
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
[ $# -ge 5 ] || usage
name="$1"; stem="$2"; prompt="$3"; shift 3
notes=1; opts=()
for a in "$@"; do
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
done
[ "${#opts[@]}" -ge 2 ] || { echo "an ask needs at least 2 options" >&2; exit 1; }
# Validated through the SAME normaliser the page uses, so a session cannot
# post a question the renderer would refuse. stdlib only — no venv needed.
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError, write_ask
booth, stem, prompt, *opts = sys.argv[1:]
try:
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
except AskError as exc:
sys.exit("bad ask: %s" % exc)
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
echo "$URL/b/$name/#ask-$stem"
;;
asks)
[ $# -ge 1 ] || usage
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import list_asks
asks = list_asks(pathlib.Path(sys.argv[1]))
if not asks:
print("no asks in this booth")
for a in asks:
if a["error"]:
state = "BROKEN " + a["error"]
elif a["answer"]:
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
else:
state = "open"
print("%-24s %s" % (a["stem"], state))
' "$DATA/$1"
;;
answer)
# booth answer <name> <stem> [--wait [SECS]]
[ $# -ge 2 ] || usage
name="$1"; stem="$2"; shift 2
wait_s=0
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
f="$DATA/$name/$stem.answer.json"
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
# Poll, do not inotify: the answer is written by a different process via
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
deadline=$(( $(date +%s) + wait_s ))
while [ ! -f "$f" ]; do
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
sleep 2
done
cat -- "$f"
;;
*) usage ;;
esac