The index card showed a name, an item count and a countdown, and nothing
the poster chose. An agent with something to show therefore had no way to
make the booth say "look at this" and posted a URL to the link board
instead — which is why 145 of that board's 210 rows (69%) ended up
pointing at booths that had already been swept. The board was absorbing a
job it was never shaped for. This is the shape.
Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.
WHAT IS WHERE
- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
scripts/booth imports it under the system python3 with no venv, and a
cross-import between two stdlib-only modules is a second way for that
invariant to break. It joins the shared test_stdlib_only list and keeps
a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
every index load, so a manifest that cannot be parsed costs that booth's
provenance and nothing else. That is the v0.2.2 lesson applied before the
same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
Folding "cannot be read" into "never said" would hide the one case
somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
no listing can see it mid-write.
THREE OPERATOR CALLS, 2026-09-22
Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.
NO EXEMPTION LIST
A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.
ALSO
tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.
310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.
NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
473 lines
21 KiB
Bash
Executable File
473 lines
21 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# booth — post media and links to The Booth (dead simple). A booth is just a
|
||
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
|
||
# back.
|
||
#
|
||
# booth new <name> [--why W] [--title T]
|
||
# make an empty booth, print its URL
|
||
# booth add <name> <file>... [--why W] [--title T]
|
||
# copy files into a booth (creates it), print URL
|
||
# booth url <name> print a booth's URL
|
||
# booth ls list booths (kept ones marked ★)
|
||
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
||
#
|
||
# 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
|
||
#
|
||
# booth ask <name> <id> <prompt> <option>... [--no-notes]
|
||
# pose a multiple-choice question in a booth
|
||
# booth marks <name> [--wait [SECS]] print every mark in a booth as JSON;
|
||
# --wait blocks while any pick is still open
|
||
# booth answer <name> <id> [--wait [SECS]]
|
||
# print ONE pick's answer (exit 1 if unanswered);
|
||
# --wait polls until it lands (default 3600 s)
|
||
#
|
||
# EXIT CODES for the two reading verbs. A read that FAILED gets its own code so
|
||
# a caller can tell "not yet" from "the file is damaged" — conflating them is
|
||
# how a broken `.marks.json` used to look like an unanswered question and wait
|
||
# out the full hour.
|
||
# marks 0 read ok · 1 --wait timed out with picks open · 3 unreadable
|
||
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable
|
||
# booth marks-import <name> import legacy *.ask.json into .marks.json
|
||
# booth asks <name> alias for `marks` (deprecated)
|
||
#
|
||
# MARKS. One primitive for operator judgment attached to an artifact:
|
||
# pick — one of N options a session declared in advance (this is `ask`)
|
||
# note — free text the operator volunteered
|
||
# flag — the operator pointing at one item
|
||
# All three are written by the OPERATOR IN THE BROWSER and read by the session.
|
||
# There are no `note` / `flag` verbs here on purpose: this CLI is the session's
|
||
# side of the loop, and a session does not author the operator's judgment.
|
||
#
|
||
# A session needs the operator to pick one of N things — which render, which
|
||
# plan, go/no-go — and act on the pick. `ask` declares it; the page renders a
|
||
# radio form with a notes field; submitting records the judgment. `answer --wait`
|
||
# blocks until it lands and prints it, so a session can
|
||
# `booth ask … && booth answer --wait …` and carry on. Re-answering overwrites:
|
||
# a mark is the CURRENT judgment, not a log. Several questions in ONE form: pass
|
||
# a `questions` list (see README § Asks); every verb handles both shapes.
|
||
#
|
||
# Marks live in ONE file per booth, `<booth>/.marks.json`, so "does this booth
|
||
# still owe an answer?" is a single read. Remote sessions have no filesystem
|
||
# access, so they poll the HTTP mirror instead:
|
||
# http://10.100.10.50:8090/b/<name>/marks.json
|
||
#
|
||
# 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
|
||
# sweep and moves it into its own lane at the top of the index. Use it for
|
||
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
|
||
# the sentinel, so putting a board back under the sweeper costs nothing.
|
||
#
|
||
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
|
||
# NOW — it announces that the board was kept, so wiping something durable is
|
||
# never silent. In the web UI it is two deliberate steps: `release` on the kept
|
||
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
|
||
# it from there.
|
||
#
|
||
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
|
||
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
|
||
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
|
||
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
|
||
#
|
||
# `link` is the reason the exception exists: agent sessions hand the operator
|
||
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
||
# instead, with provenance, so they outlive the session that produced them.
|
||
#
|
||
# ANNOUNCE YOUR BOOTH. `--why` is one line saying what the operator is looking
|
||
# at and why he should care; it lands on the index card and on the booth page
|
||
# beside your handle, taken from $ALTHING_HANDLE. It is optional and nothing
|
||
# breaks without it — but a booth that cannot say what it is has no way to ask
|
||
# for attention except by posting its URL somewhere, which is exactly how the
|
||
# link board came to be 69% dead rows. The booth is the place to say it.
|
||
#
|
||
# booth add r18-ab out/*.png --why "pick the denoiser, left column is v3"
|
||
#
|
||
# Re-announcing (a second `new` or `add` on the same booth) updates the why and
|
||
# KEEPS the original creation stamp: the booth appeared once.
|
||
#
|
||
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
|
||
# rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||
set -euo pipefail
|
||
|
||
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
|
||
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
|
||
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
|
||
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
|
||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||
|
||
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
|
||
# appear, so `booth add b *.png --why "..."` and `booth add b --why "..." *.png`
|
||
# both work — a glob is usually last and a flag usually after it, but nothing
|
||
# enforces that and a session should not have to care.
|
||
WHY=""; TITLE=""; ARGS=()
|
||
strip_announce_flags() {
|
||
ARGS=()
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--why) [ $# -ge 2 ] || usage; WHY="$2"; shift 2 ;;
|
||
--title) [ $# -ge 2 ] || usage; TITLE="$2"; shift 2 ;;
|
||
--why=*) WHY="${1#--why=}"; shift ;;
|
||
--title=*) TITLE="${1#--title=}"; shift ;;
|
||
*) ARGS+=("$1"); shift ;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
# Announce a booth. Goes through booth/manifest.py rather than printf-ing JSON
|
||
# from the shell, because a why containing a quote, a backslash or a newline is
|
||
# not an edge case — it is a sentence somebody wrote.
|
||
announce() {
|
||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" \
|
||
BOOTH_ANN_DIR="$1" BOOTH_ANN_HANDLE="$2" \
|
||
BOOTH_ANN_TITLE="${3:-}" BOOTH_ANN_WHY="${4:-}" python3 -c '
|
||
import os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
try:
|
||
from booth.manifest import write_manifest
|
||
write_manifest(pathlib.Path(os.environ["BOOTH_ANN_DIR"]),
|
||
os.environ["BOOTH_ANN_HANDLE"],
|
||
title=os.environ["BOOTH_ANN_TITLE"],
|
||
why=os.environ["BOOTH_ANN_WHY"])
|
||
except Exception as exc:
|
||
# A booth that could not announce itself is still a booth. Say so on stderr
|
||
# and carry on: failing `booth add` over its metadata would lose the files
|
||
# the session just copied, which is a far worse trade.
|
||
print(f"booth: could not write the announcement: {exc}", file=sys.stderr)
|
||
'
|
||
}
|
||
|
||
# Who is posting. The same chain `link` uses for its rows, so provenance means
|
||
# the same thing on the board and on the card.
|
||
whoami_handle() {
|
||
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||
}
|
||
|
||
usage() {
|
||
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||
exit 2
|
||
}
|
||
|
||
cmd="${1:-}"; shift || true
|
||
case "$cmd" in
|
||
new)
|
||
strip_announce_flags "$@"
|
||
set -- ${ARGS+"${ARGS[@]}"}
|
||
[ $# -ge 1 ] || usage
|
||
mkdir -p -- "$DATA/$1"
|
||
announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
|
||
echo "$URL/b/$1/"
|
||
;;
|
||
add)
|
||
strip_announce_flags "$@"
|
||
set -- ${ARGS+"${ARGS[@]}"}
|
||
[ $# -ge 2 ] || usage
|
||
name="$1"; shift
|
||
mkdir -p -- "$DATA/$name"
|
||
cp -- "$@" "$DATA/$name/"
|
||
announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
|
||
echo "$URL/b/$name/"
|
||
;;
|
||
url)
|
||
[ $# -ge 1 ] || usage
|
||
echo "$URL/b/$1/"
|
||
;;
|
||
ls)
|
||
[ -d "$DATA" ] || exit 0
|
||
for d in "$DATA"/*/; do
|
||
[ -d "$d" ] || continue
|
||
n="$(basename -- "$d")"
|
||
if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo " $n"; fi
|
||
done
|
||
;;
|
||
rm)
|
||
[ $# -ge 1 ] || usage
|
||
# Say so when the thing destroyed was durable. Not a block — a CLI user
|
||
# naming a booth is being explicit — but a kept board disappearing must not
|
||
# look identical to run output disappearing.
|
||
was_kept=""
|
||
[ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
|
||
rm -rf -- "${DATA:?}/$1"
|
||
echo "wiped $1$was_kept"
|
||
;;
|
||
keep)
|
||
[ $# -ge 1 ] || usage
|
||
[ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
|
||
: > "$DATA/$1/$KEEP"
|
||
echo "kept (exempt from the sweep): $URL/b/$1/"
|
||
;;
|
||
unkeep)
|
||
[ $# -ge 1 ] || usage
|
||
rm -f -- "$DATA/$1/$KEEP"
|
||
echo "unkept — $1 rejoins the 24h sweep"
|
||
;;
|
||
blur|unblur)
|
||
# ⚠ COSMETIC ONLY. A blurred item is still served at its own URL, still in
|
||
# the zip, still on disk. This hides it from a glance — a shoulder, a
|
||
# screen-share, a scroll past something you did not want full-size. The
|
||
# Booth has no auth by design: if a thing must not be SEEN, it must not be
|
||
# in a booth.
|
||
[ $# -ge 2 ] || usage
|
||
b="$1"; shift
|
||
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
|
||
f="$DATA/$b/$BLUR"
|
||
for item in "$@"; do
|
||
item="${item#"$DATA/$b/"}"; item="${item#/}"
|
||
case "$item" in
|
||
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
|
||
esac
|
||
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
|
||
touch "$f"
|
||
if [ "$cmd" = blur ]; then
|
||
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
|
||
else
|
||
grep -vxF -- "$item" "$f" > "$f.tmp" || true
|
||
mv -- "$f.tmp" "$f"
|
||
fi
|
||
done
|
||
# An empty marker is a lie by omission — `ls -a` should say whether
|
||
# anything here is blurred at all.
|
||
[ -s "$f" ] || rm -f -- "$f"
|
||
if [ "$cmd" = blur ]; then
|
||
echo "blurred (cosmetic — still served): $URL/b/$b/"
|
||
else
|
||
echo "un-blurred: $URL/b/$b/"
|
||
fi
|
||
;;
|
||
link)
|
||
[ $# -ge 1 ] || usage
|
||
link_url="$1"; shift
|
||
desc="${*:-}"
|
||
board="$DATA/$LINKS_BOARD"
|
||
mkdir -p -- "$board"
|
||
: > "$board/$KEEP" # the board is durable by definition
|
||
# The board announces itself as the SERVICE's, not as any one agent's:
|
||
# seventeen handles post to it, so no handle owns it. Idempotent — a second
|
||
# link keeps the original creation stamp.
|
||
announce "$board" "booth" "$LINKS_BOARD" \
|
||
"the standing link board — every agent session posts here"
|
||
# Provenance, because a bare URL is unreadable three days later: who posted
|
||
# it, from where, and when.
|
||
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||
when="$(date '+%Y-%m-%d %H:%M')"
|
||
# 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 -- "$(readlink -f -- "$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"
|
||
;;
|
||
ask)
|
||
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
|
||
[ $# -ge 5 ] || usage
|
||
name="$1"; mid="$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 "a pick needs at least 2 options" >&2; exit 1; }
|
||
# Validated through the SAME normaliser the page uses, so a session cannot
|
||
# declare a question the renderer would refuse. stdlib only — no venv needed.
|
||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
|
||
import os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
from booth.asks import AskError
|
||
from booth.marks import MarksCorrupt, declare_pick
|
||
booth, mid, prompt, *opts = sys.argv[1:]
|
||
try:
|
||
declare_pick(pathlib.Path(booth), mid,
|
||
{"prompt": prompt, "options": opts,
|
||
"notes": os.environ["ASK_NOTES"] == "1"})
|
||
except AskError as exc:
|
||
sys.exit("bad pick: %s" % exc)
|
||
except MarksCorrupt as exc:
|
||
sys.exit("this booth'"'"'s .marks.json is damaged, so nothing was written: %s" % exc)
|
||
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
|
||
echo "$URL/b/$name/#mark-$mid"
|
||
;;
|
||
marks|asks)
|
||
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
|
||
#
|
||
# EXIT CODES. 0 = the read succeeded and the document is on stdout; 1 =
|
||
# --wait gave up with picks still open (the document is still printed); 3 =
|
||
# the marks could not be read at all. A reader that CRASHED must never look
|
||
# like an answer — the old shape printed a traceback and exited 0, so a
|
||
# caller piping to `jq` saw success and got nothing.
|
||
#
|
||
# Whether anything is still open is in the payload's `open` list. The read
|
||
# verb does not encode it in its status: a successful read is a success.
|
||
[ $# -ge 1 ] || usage
|
||
name="$1"; shift
|
||
wait_s=0
|
||
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||
# Poll, do not inotify: the judgment 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 :; do
|
||
# CAPTURED, not streamed. Printing inside the loop wrote one whole JSON
|
||
# document per poll, so `booth marks b --wait | jq` got several values
|
||
# concatenated and could parse none of them. The wait is a wait; the
|
||
# print is the result, and it happens once.
|
||
rc=0
|
||
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||
import json, os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
try:
|
||
from booth.marks import as_dict, marks_for, open_marks, read_error
|
||
booth = pathlib.Path(sys.argv[1])
|
||
# Ask FIRST whether the file is readable. `marks_for` answers "no marks"
|
||
# for a damaged file, which is the right answer for a page and the wrong
|
||
# one for a session that wants to know whether its question survived.
|
||
broken = read_error(booth)
|
||
if broken:
|
||
print(f"booth: {broken}", file=sys.stderr)
|
||
sys.exit(3)
|
||
marks = marks_for(booth)
|
||
doc = json.dumps({"marks": [as_dict(m) for m in marks],
|
||
"open": [m.id for m in open_marks(marks)]},
|
||
ensure_ascii=False, indent=2)
|
||
except Exception as exc:
|
||
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||
sys.exit(3)
|
||
print(doc)
|
||
sys.exit(2 if open_marks(marks) else 0)
|
||
' "$DATA/$name")" || rc=$?
|
||
case "$rc" in
|
||
0) printf '%s\n' "$out"; exit 0 ;; # read ok, nothing open
|
||
2) if [ "$wait_s" -eq 0 ]; then printf '%s\n' "$out"; exit 0; fi ;;
|
||
*) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||
esac
|
||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||
printf '%s\n' "$out"
|
||
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
|
||
fi
|
||
sleep 2
|
||
done
|
||
;;
|
||
answer)
|
||
# booth answer <name> <id> [--wait [SECS]]
|
||
[ $# -ge 2 ] || usage
|
||
name="$1"; mid="$2"; shift 2
|
||
wait_s=0
|
||
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||
deadline=$(( $(date +%s) + wait_s ))
|
||
while :; do
|
||
rc=0
|
||
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||
import json, os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
try:
|
||
from booth.marks import marks_for, read_error
|
||
booth, mid = sys.argv[1:3]
|
||
broken = read_error(pathlib.Path(booth))
|
||
if broken:
|
||
print(f"booth: {broken}", file=sys.stderr)
|
||
sys.exit(3)
|
||
# id AND shape, matching the web route. Matching on id alone reported a
|
||
# note id as "unanswered" and then polled it for an hour — a question that
|
||
# could never be answered because it was never a question.
|
||
m = next((x for x in marks_for(pathlib.Path(booth))
|
||
if x.id == mid and x.shape == "pick"), None)
|
||
except Exception as exc:
|
||
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||
sys.exit(3)
|
||
if m is None:
|
||
sys.exit(2)
|
||
if m.answer is None:
|
||
sys.exit(1)
|
||
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||
' "$DATA/$name" "$mid")" || rc=$?
|
||
case "$rc" in
|
||
0) printf '%s\n' "$out"; exit 0 ;;
|
||
2) echo "no such pick: $name/$mid" >&2; exit 2 ;;
|
||
# A read that FAILED is not "not yet". Conflating them sent --wait
|
||
# spinning for the full hour on a broken file and then blamed the
|
||
# operator for not answering.
|
||
3) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||
esac
|
||
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
|
||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
|
||
fi
|
||
sleep 2
|
||
done
|
||
;;
|
||
marks-import)
|
||
# booth marks-import <name> — idempotent, and it deletes nothing
|
||
[ $# -ge 1 ] || usage
|
||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||
import os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
from booth.marks import import_legacy_asks
|
||
made = import_legacy_asks(pathlib.Path(sys.argv[1]))
|
||
if not made:
|
||
print("nothing to import (or already imported)")
|
||
for m in made:
|
||
print("imported %-24s %s" % (m.id, m.error or ("answered" if m.answer else "open")))
|
||
' "$DATA/$1"
|
||
;;
|
||
*) usage ;;
|
||
esac
|