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.
This commit is contained in:
vh
2026-09-06 02:29:22 -07:00
parent 22ec06fcaa
commit 8060f8cb9a
7 changed files with 472 additions and 37 deletions
+195
View File
@@ -8,9 +8,13 @@ from fastapi.testclient import TestClient
from booth.app import (
LINKS_FILE,
PINS_FILE,
link_entry_id,
order_for_display,
parse_link_entries,
read_pins,
remove_link_entry,
toggle_pin,
booth_age_seconds,
FAVICON_LINK,
KEEP_MARKER,
@@ -1090,3 +1094,194 @@ def test_cli_unlink_of_a_stale_id_leaves_the_board_alone(tmp_path):
assert r.returncode != 0
assert len(parse_link_entries((tmp_path / "links" / LINKS_FILE).read_text())) == 1
# ---- the standing link board: pin (favorite) + ordering + multi-select ------
#
# The board renders pinned-first then newest-first, a ★ pins a row to the top,
# and a checkbox column feeds a bulk delete. Pin state lives in a `.pins` sidecar
# (content ids, one per line) so links.md stays a pure append log and a row's id
# never changes just because it was pinned.
def test_read_pins_empty_when_no_file(tmp_path):
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
assert read_pins(b) == set()
assert not (b / PINS_FILE).exists()
def test_toggle_pin_round_trips(tmp_path):
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
eid = parse_link_entries((b / LINKS_FILE).read_text())[0]["id"]
assert toggle_pin(b, eid) is True
assert read_pins(b) == {eid}
assert toggle_pin(b, eid) is False
assert read_pins(b) == set()
def test_pins_persist_in_a_dotfile_not_in_links_md(tmp_path):
"""The whole reason for the sidecar: links.md stays untouched by a pin, so it
remains a pure atomic-append log and the row's content id does not drift."""
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
before = (b / LINKS_FILE).read_text()
eid = parse_link_entries(before)[0]["id"]
toggle_pin(b, eid)
assert (b / LINKS_FILE).read_text() == before, "pinning must not rewrite links.md"
assert (b / PINS_FILE).read_text().strip() == eid
assert PINS_FILE.startswith("."), "pin file must be a dotfile so listings skip it"
def test_order_for_display_pins_first_then_newest(tmp_path):
b = _board(tmp_path,
_ROW.format(d="oldest", u="http://1/", w="x"),
_ROW.format(d="middle", u="http://2/", w="x"),
_ROW.format(d="newest", u="http://3/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
ids = {e["desc"]: e["id"] for e in entries}
ordered = order_for_display(entries, {ids["middle"]})
# pinned (middle) leads; the rest fall in newest-first order
assert [e["desc"] for e in ordered] == ["middle", "newest", "oldest"]
assert ordered[0]["pinned"] is True
assert all(e["pinned"] is False for e in ordered[1:])
def test_order_for_display_is_newest_first_with_no_pins(tmp_path):
b = _board(tmp_path,
_ROW.format(d="first", u="http://1/", w="x"),
_ROW.format(d="last", u="http://2/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
ordered = order_for_display(entries, set())
assert [e["desc"] for e in ordered] == ["last", "first"] # newest on top
def test_order_for_display_does_not_mutate_parse_output(tmp_path):
"""Callers that want the file-order view (the CLI) must not see a `pinned`
key leak into parse_link_entries' dicts."""
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
order_for_display(entries, {entries[0]["id"]})
assert "pinned" not in entries[0]
def test_orphan_pin_is_inert_not_shown_as_pinned(tmp_path):
"""A pin id that no longer matches any row must simply not render as pinned —
never crash, never resurrect a phantom row."""
b = _board(tmp_path, _ROW.format(d="a", u="http://a/", w="x"))
entries = parse_link_entries((b / LINKS_FILE).read_text())
ordered = order_for_display(entries, {"deadbeef"}) # id matches nothing
assert [e["desc"] for e in ordered] == ["a"]
assert ordered[0]["pinned"] is False
def test_remove_link_entry_also_unpins(tmp_path):
"""Removing a row drops its pin, so .pins does not accumulate dead ids."""
b = _board(tmp_path,
_ROW.format(d="keep", u="http://a/", w="x"),
_ROW.format(d="gone", u="http://b/", w="y"))
gone = next(e for e in parse_link_entries((b / LINKS_FILE).read_text()) if e["desc"] == "gone")
keep = next(e for e in parse_link_entries((b / LINKS_FILE).read_text()) if e["desc"] == "keep")
toggle_pin(b, gone["id"])
toggle_pin(b, keep["id"])
assert read_pins(b) == {gone["id"], keep["id"]}
remove_link_entry(b, gone["id"])
assert read_pins(b) == {keep["id"]}, "the removed row's pin is dropped, the other kept"
# ---- HTTP: /pin and /unlink-many --------------------------------------------
def test_pin_endpoint_toggles(client):
c, data = client
b = _board(data, _ROW.format(d="a", u="http://a/", w="x"))
eid = parse_link_entries((b / LINKS_FILE).read_text())[0]["id"]
r = c.post("/b/links/pin", data={"entry": eid}, follow_redirects=False)
assert r.status_code == 303
assert read_pins(b) == {eid}
c.post("/b/links/pin", data={"entry": eid})
assert read_pins(b) == set()
def test_pin_endpoint_rejects_a_bad_booth(client):
c, _ = client
assert c.post("/b/nope/pin", data={"entry": "x"}).status_code == 404
def test_unlink_many_removes_all_selected(client):
c, data = client
b = _board(data,
_ROW.format(d="a", u="http://a/", w="x"),
_ROW.format(d="b", u="http://b/", w="y"),
_ROW.format(d="c", u="http://c/", w="z"))
es = parse_link_entries((b / LINKS_FILE).read_text())
a_id = next(e["id"] for e in es if e["desc"] == "a")
c_id = next(e["id"] for e in es if e["desc"] == "c")
r = c.post("/b/links/unlink-many", data={"sel": [a_id, c_id]}, follow_redirects=False)
assert r.status_code == 303
left = [e["desc"] for e in parse_link_entries((b / LINKS_FILE).read_text())]
assert left == ["b"]
def test_unlink_many_empty_selection_is_a_noop(client):
c, data = client
_board(data, _ROW.format(d="a", u="http://a/", w="x"))
r = c.post("/b/links/unlink-many", data={}, follow_redirects=False)
assert r.status_code == 303
assert len(parse_link_entries((data / "links" / LINKS_FILE).read_text())) == 1
def test_unlink_many_rejects_a_bad_booth(client):
c, _ = client
assert c.post("/b/nope/unlink-many", data={"sel": "x"}).status_code == 404
# ---- HTTP: the board renders the new controls in the right order ------------
def test_board_renders_pin_and_multiselect_controls(client):
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"))
body = _body(c, "/b/links/")
assert "/b/links/pin" in body # per-row pin control
assert "/b/links/unlink-many" in body # bulk delete
assert 'name="sel"' in body # selection checkbox
assert 'class="board-pin' in body # the ★ toggle
def test_board_page_orders_newest_first_and_pinned_on_top(client):
c, data = client
b = _board(data,
_ROW.format(d="first-posted", u="http://1/", w="x"),
_ROW.format(d="last-posted", u="http://2/", w="y"))
body = _body(c, "/b/links/")
assert body.index("last-posted") < body.index("first-posted"), "newest on top by default"
older = next(e for e in parse_link_entries((b / LINKS_FILE).read_text())
if e["desc"] == "first-posted")
c.post("/b/links/pin", data={"entry": older["id"]})
body2 = _body(c, "/b/links/")
assert body2.index("first-posted") < body2.index("last-posted"), "pinned row floats to the top"
assert "1 pinned" in body2