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