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