Files
booth/booth/links.py
T
vh 8060f8cb9a feat(booth): pin/favorite, multi-select delete, newest-first link board
The standing link board grew from a flat oldest-first list with a per-row
× into a manageable board: newest links lead, favorites stay on top, and
several dead links can go in one pass.

- Ordering: order_for_display() renders pinned rows first, then newest-first
  within each group (the board is an append log, so newest = most recently
  posted — the row you usually came to grab).
- Pin/favorite: a per-row ★ toggles pinned state via POST /b/<name>/pin.
  State lives in a .pins sidecar dotfile (one content id per line), NOT
  inline in links.md — so links.md stays a pure atomic-append log (many
  sessions post concurrently) and a row's content id never changes just
  because it was pinned. remove_link_entry drops a removed row's pin;
  orphaned pins are inert (renderer only stars a live id).
- Multi-select delete: checkboxes feed POST /b/<name>/unlink-many (repeated
  'sel' content ids), with a select-all box and a live count. The per-row ×
  stays for single removal.
- One <form> with formaction buttons, so checkboxes, ×, ★, and bulk delete
  coexist without nested forms AND all work with JS off; JS only adds
  select-all and the live count. Per-row × confirm reads desc/url from
  data-* attrs, so an arbitrary posted description can't break into the JS.
- Every action is keyed by content id, never row position — same race-safety
  the existing × has, extended to the bulk path.
- Fixed pre-existing undefined --fg/--bg CSS refs in the board styles.

Tests: +19 (pins round-trip, ordering, orphan-inert, remove-unpins, /pin
and /unlink-many endpoints, board render + order). Full suite 102 passing.
Deployed to nh3-dev booth.service; verified live (newest-first, pin
round-trip, bulk delete) against the real 31-row board with no data loss.
2026-09-06 02:29:22 -07:00

197 lines
8.0 KiB
Python

"""Standing link board: parse and prune the multi-writer link log.
STDLIB ONLY, ON PURPOSE. This lives apart from app.py because the `booth` CLI
needs it and the CLI must not require the service's venv — importing app.py
drags in FastAPI, so a shell tool that only wants to delete a line would need
a web framework installed. The board is a text file; its logic should cost a
text file's worth of dependencies.
"""
from __future__ import annotations
import fcntl
import hashlib
import os
import re
from pathlib import Path
# ---- the standing link board ------------------------------------------------
#
# One booth (`links` by convention) is a MULTI-WRITER append log: every agent
# session on the fleet posts operator-facing URLs to it so they outlive the
# terminal scrollback that would otherwise bury them. That makes it the one
# booth where "delete the whole folder" is the wrong granularity — a single
# dead link has to be removable without taking the other thirty with it.
#
# Entries are identified by a CONTENT HASH, never by line number. Indexes are
# racy here by construction: another session can append between the moment you
# list the board and the moment you remove a row, and index-based removal would
# then delete the wrong line. A content id is stable against concurrent
# appends — the worst case is that the row is already gone, which is reported
# rather than silently deleting a neighbour.
LINKS_FILE = "links.md"
LINK_LOCK = ".links.lock"
# Pin state lives in a sidecar dotfile — one content id per line — NOT inline in
# links.md. Three reasons this is the right seam:
# * links.md stays a pure append log: `booth link` remains a single atomic
# O_APPEND write, which is what lets many fleet sessions post concurrently
# without a lock on the common path.
# * pinning never rewrites a row, so a row's content id (its identity for
# removal) never changes just because it was pinned.
# * it mirrors the `.forever` sentinel already in play — a dotfile the listing
# code skips, so it costs nothing in item counts or galleries.
# Orphaned ids (a row hand-edited so its id drifts, or removed) are inert: the
# renderer only marks a row pinned when a live row still carries that id, and
# remove_link_entry drops the id as it deletes the row.
PINS_FILE = ".pins"
# - [description](url) <sub>· who · when</sub>
_LINK_RE = re.compile(
r"^- \[(?P<desc>.*?)\]\((?P<url>[^)]*)\)"
r"(?:\s*<sub>·\s*(?P<who>[^·]*?)\s*·\s*(?P<when>[^<]*?)\s*</sub>)?\s*$"
)
def link_entry_id(raw: str) -> str:
"""Stable short id for a board row. Content-addressed, so it survives
concurrent appends by other sessions and cannot drift like an index."""
return hashlib.sha1(raw.strip().encode()).hexdigest()[:8]
def parse_link_entries(text: str) -> list[dict]:
"""Rows of the standing link board, newest last (posting order).
Non-matching lines (a heading someone added by hand, a blank) are skipped
rather than rejected: the board is a plain markdown file the operator is
explicitly allowed to edit, so the parser must tolerate prose around the
rows it understands.
"""
out: list[dict] = []
for i, raw in enumerate(text.splitlines()):
m = _LINK_RE.match(raw.strip())
if not m:
continue
out.append({
"id": link_entry_id(raw),
"raw": raw,
"line": i,
"desc": (m.group("desc") or "").strip(),
"url": (m.group("url") or "").strip(),
"who": (m.group("who") or "").strip(),
"when": (m.group("when") or "").strip(),
})
return out
def remove_link_entry(board: Path, entry_id: str) -> dict | None:
"""Remove one row by content id. Returns the removed entry, or None.
Held under an exclusive flock on a sidecar lock file for the whole
read-modify-write, and the CLI's append path takes the same lock — so a
concurrent `booth link` cannot be lost to this rewrite. Written to a temp
file and os.replace'd, so a crash mid-write cannot truncate the board.
"""
path = board / LINKS_FILE
if not path.exists():
return None
lock = board / LINK_LOCK
lock.touch(exist_ok=True)
with lock.open("r+") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
text = path.read_text()
kept, removed = [], None
for raw in text.splitlines(keepends=True):
if removed is None and link_entry_id(raw) == entry_id:
m = _LINK_RE.match(raw.strip())
if m:
removed = {"id": entry_id, "raw": raw.rstrip("\n"),
"desc": (m.group("desc") or "").strip(),
"url": (m.group("url") or "").strip()}
continue
kept.append(raw)
if removed is None:
return None
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("".join(kept))
os.replace(tmp, path)
# The row is gone; drop any pin that referenced it so .pins does not
# accumulate dead ids. Same critical section, so a concurrent pin
# toggle cannot race this rewrite.
pins = _read_pins_unlocked(board)
if entry_id in pins:
pins.discard(entry_id)
_write_pins_unlocked(board, pins)
return removed
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
# ---- pins: favorite a row so it floats to the top --------------------------
def _read_pins_unlocked(board: Path) -> set[str]:
path = board / PINS_FILE
if not path.exists():
return set()
try:
return {ln.strip() for ln in path.read_text().splitlines() if ln.strip()}
except OSError:
return set()
def _write_pins_unlocked(board: Path, ids: set[str]) -> None:
"""Atomic replace of the pins file. Caller must hold the board lock."""
path = board / PINS_FILE
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("".join(f"{i}\n" for i in sorted(ids)))
os.replace(tmp, path)
def read_pins(board: Path) -> set[str]:
"""Pinned entry ids for a board. Missing file → empty set. Lock-free: a set
read of a dotfile the sweeper never touches, safe to call on the render path."""
return _read_pins_unlocked(Path(board))
def toggle_pin(board: Path, entry_id: str) -> bool:
"""Flip one row's pinned state. Returns the NEW state (True = now pinned).
Held under the same sidecar flock as append and remove, so a toggle cannot
interleave with a board rewrite. Pure add/remove of the id — orphan pruning
is the remover's job (remove_link_entry) and the renderer's (a pin with no
live row is simply not shown as pinned)."""
board = Path(board)
lock = board / LINK_LOCK
lock.touch(exist_ok=True)
with lock.open("r+") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
pins = _read_pins_unlocked(board)
if entry_id in pins:
pins.discard(entry_id)
new_state = False
else:
pins.add(entry_id)
new_state = True
_write_pins_unlocked(board, pins)
return new_state
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
"""Board rows for the web view: pinned first, then newest-first in each group.
`entries` arrive from parse_link_entries in file order (oldest first). Each
returned row is a copy stamped with a `pinned` bool (the input dicts are left
untouched, so parse output stays a faithful file-order view for callers that
want it — e.g. the CLI). Within both the pinned and the unpinned group the
most recently appended row leads, which is what "newest on top" means for an
append log.
"""
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
stamped.reverse() # newest first
return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]