From 8060f8cb9a71f93ea4aeade19274cec36f5d0098 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 6 Sep 2026 02:29:22 -0700 Subject: [PATCH] feat(booth): pin/favorite, multi-select delete, newest-first link board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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//unlink-many (repeated 'sel' content ids), with a select-all box and a live count. The per-row × stays for single removal. - One
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. --- README.md | 32 +++++- booth/app.py | 39 +++++++- booth/links.py | 89 +++++++++++++++++ booth/templates/base.html | 54 +++++++--- booth/templates/booth.html | 98 ++++++++++++++++--- pyproject.toml | 2 +- tests/test_booth.py | 195 +++++++++++++++++++++++++++++++++++++ 7 files changed, 472 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index c3dcb19..769672f 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ to a safe basename (no path traversal). | `POST /b//keep` | Pin a booth — exempt from the sweep | | `POST /b//unkeep` | Release the pin (the UI's "release" button on kept cards) | | `POST /b//unlink` | Remove ONE row from a link board (form field `entry` = content id) | +| `POST /b//unlink-many` | Remove SEVERAL rows — the multi-select delete (repeated form field `sel` = content ids) | +| `POST /b//pin` | Toggle a row's pinned/favorite state (form field `entry` = content id) | | `DELETE /b/` | Wipe a booth (curl/API) | | `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target | @@ -155,6 +157,19 @@ without taking the other thirty with it. It renders as real UI, not a markdown blob: each row shows the description, URL and provenance (who posted it, when), with a copy button and a per-row ×. +**Order: pinned first, then newest on top.** The board is an append log, so the +most recently posted link leads — the one you almost certainly came to grab. +Rows you want to keep in view regardless of churn get the **★** (pin/favorite), +which floats them to a group at the very top; click it again to unpin. The +header shows `N pinned` when any are. + +**Multi-select delete.** Tick the checkbox on any set of rows and hit +**🗑 delete** to remove them all in one go (with a count confirmation). The +select-all box in the header toggles the lot. The per-row × is still there for +a single quick removal. Everything — checkboxes, ×, ★, bulk delete — works with +JavaScript off (plain form POSTs via `formaction`); JS only adds select-all and +the live count. + ```bash booth links # row number, entry id, raw row booth unlink 3 # by row number @@ -165,15 +180,24 @@ booth unlink 8b40e0a5 # by entry id — what the × posts append-only and multi-writer: another session can post between the moment you list it and the moment you remove a row, so an index would delete a neighbour. An id either matches the row you saw or matches nothing. A row number typed at -the CLI is resolved to its id *before* anything is deleted. +the CLI is resolved to its id *before* anything is deleted. The multi-select +delete (`/unlink-many`) carries the same guarantee per selected id. An id is exactly 8 hex characters, which is how the CLI tells ids from row numbers — roughly one id in forty is all digits, so "is it numeric" is not a safe test. -Appends (`booth link`) and prunes (`booth unlink`, the ×) take the same -`flock` on `.links.lock`, so a post cannot be lost inside a prune's -read-modify-write window. +**Pin state lives in a `.pins` sidecar** (one content id per line), never inline +in `links.md`. That keeps `links.md` a pure append log — `booth link` stays a +single atomic write, which is what lets many sessions post concurrently — and +means pinning a row never changes its content id. A pin whose row is later +removed is dropped automatically; a pin orphaned by a hand-edit is inert (the +renderer only stars a row a live id still matches). Pins are a UI action; there +is no `booth pin` CLI yet. + +Appends (`booth link`) and prunes (`booth unlink`, `unlink-many`, the ×) take +the same `flock` on `.links.lock`, and pin toggles take it too, so a post cannot +be lost inside a prune's or a toggle's read-modify-write window. ### Deleting a kept board diff --git a/booth/app.py b/booth/app.py index 314b727..f888763 100644 --- a/booth/app.py +++ b/booth/app.py @@ -78,9 +78,13 @@ KEEP_MARKER = ".forever" from booth.links import ( # noqa: E402 LINK_LOCK, LINKS_FILE, + PINS_FILE, link_entry_id, + order_for_display, parse_link_entries, + read_pins, remove_link_entry, + toggle_pin, ) @@ -604,11 +608,16 @@ def create_app( if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE) ], # A booth carrying links.md is the standing link board: render - # its rows as real UI (link, provenance, per-row remove) instead - # of a markdown blob you can only edit by hand. Empty list for - # every other booth, so the template branch simply does not fire. + # its rows as real UI (link, provenance, pin, per-row + bulk + # remove) instead of a markdown blob you can only edit by hand. + # Ordered pinned-first then newest-first, each row stamped with a + # `pinned` flag. Empty list for every other booth, so the template + # branch simply does not fire. "board": ( - parse_link_entries((booth / LINKS_FILE).read_text()) + order_for_display( + parse_link_entries((booth / LINKS_FILE).read_text()), + read_pins(booth), + ) if (booth / LINKS_FILE).is_file() else [] ), "uploaded": (booth / UPLOAD_MARKER).exists(), @@ -736,6 +745,28 @@ def create_app( pass return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + @app.post("/b/{name}/unlink-many") + def board_unlink_many(name: str, sel: list[str] = Form(default=[])): + """Remove SEVERAL rows in one go — the multi-select delete. + + Each `sel` is a content id (same identity the per-row × uses), so the same + race-safety holds: an id either matches the row the operator selected or + matches nothing, never a neighbour that another session appended in the + meantime. An empty selection is a no-op, not an error. + """ + board = resolve_booth(name) + for entry_id in sel: + remove_link_entry(board, entry_id) + return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + + @app.post("/b/{name}/pin") + def board_pin(name: str, entry: str = Form(...)): + """Toggle a row's pinned (favorite) state, by content id. Pinned rows + float to the top of the board; toggling again unpins. Reversible, so no + confirmation — unlike removal.""" + toggle_pin(resolve_booth(name), entry) + return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + @app.post("/b/{name}/keep") def booth_keep(name: str): (resolve_booth(name) / KEEP_MARKER).touch() diff --git a/booth/links.py b/booth/links.py index 0cbfe2e..895b7c2 100644 --- a/booth/links.py +++ b/booth/links.py @@ -32,6 +32,20 @@ from pathlib import Path 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) · who · when _LINK_RE = re.compile( r"^- \[(?P.*?)\]\((?P[^)]*)\)" @@ -102,6 +116,81 @@ def remove_link_entry(board: Path, entry_id: str) -> dict | 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"]] diff --git a/booth/templates/base.html b/booth/templates/base.html index cf299a3..e8a8e39 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -138,32 +138,56 @@ attention with the thing you came to read. */ .board{border:1px solid var(--border-subtle);border-radius:.5rem;overflow:hidden; background:var(--rk-panel);margin:.6rem 0 1rem} - .board-head{display:flex;align-items:baseline;gap:.6rem;padding:.5rem .75rem; - border-bottom:1px solid var(--border-subtle);background:var(--bg)} + .board-head{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem; + border-bottom:1px solid var(--border-subtle);background:var(--rk-well)} + .board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto} .board-title{font-weight:600;font-size:.85rem} - .board-note{font-size:.72rem;opacity:.55} + .board-note{font-size:.72rem;color:var(--fg-3)} + .board-spacer{flex:1 1 auto} + /* Bulk delete: quiet until something is selected. Red-outline danger word, + matching the .wipe-lg language — fills on hover, greys out when disabled. */ + .board-del-sel{flex:0 0 auto;cursor:pointer;font-family:var(--font-mono);font-size:.7rem; + letter-spacing:.06em;padding:.28rem .6rem;border-radius:var(--radius-sm); + background:transparent;border:1px solid var(--aus-red);color:var(--aus-bright-red); + transition:.14s var(--ease-out)} + .board-del-sel:hover:not(:disabled){background:var(--aus-red);border-color:var(--aus-red);color:#fff} + .board-del-sel:disabled{opacity:.4;cursor:default;border-color:var(--border-default);color:var(--fg-3)} + .board-row{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem; border-bottom:1px solid var(--border-subtle);transition:background .1s} .board-row:last-child{border-bottom:0} - .board-row:hover{background:var(--bg)} + .board-row:hover{background:var(--rk-well)} + /* Pinned rows: a subtle cyan wash + a filled star. NOT a coloured left border + (Australis forbids it); the tint is enough to group them, and the ★ carries + the state at the row level. */ + .board-row.is-pinned{background:rgba(66,220,209,.05)} + .board-row.is-pinned:hover{background:rgba(66,220,209,.09)} + .board-check{flex:0 0 auto;width:1rem;height:1rem;cursor:pointer;accent-color:var(--aus-bright-cyan)} + .board-selall input{width:1rem;height:1rem;cursor:pointer;accent-color:var(--aus-bright-cyan)} + /* ★ favorite toggle. Always visible (unlike ×) because it carries state you + need to see; dim-hollow when off, warm-gold and filled when pinned. */ + .board-pin{flex:0 0 auto;border:0;background:none;cursor:pointer;line-height:1; + font-size:1.02rem;padding:.05rem .2rem;border-radius:.25rem;color:var(--fg-3); + transition:color .12s,transform .12s var(--ease-out)} + .board-pin:hover{color:var(--aus-bright-yellow);transform:scale(1.15)} + .board-pin.on{color:var(--aus-bright-yellow)} .board-main{flex:1 1 auto;min-width:0} .board-link{font-size:.9rem;text-decoration:none;font-weight:500} .board-link:hover{text-decoration:underline} - .board-url{font-size:.7rem;opacity:.45;overflow:hidden;text-overflow:ellipsis; - white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace} + .board-url{font-size:.7rem;color:var(--fg-3);opacity:.8;overflow:hidden;text-overflow:ellipsis; + white-space:nowrap;font-family:var(--font-mono)} .board-meta{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end; - gap:.05rem;font-size:.68rem;opacity:.5;white-space:nowrap} + gap:.05rem;font-size:.68rem;color:var(--fg-3);white-space:nowrap} .board-who{font-weight:600} - .board-copy,.board-rm button{opacity:0;transition:opacity .12s;flex:0 0 auto} - .board-row:hover .board-copy,.board-row:hover .board-rm button, - .board-copy:focus,.board-rm button:focus{opacity:1} - .board-rm{flex:0 0 auto;margin:0} - .board-rm button{font:inherit;font-size:1rem;line-height:1;padding:.1rem .35rem; - border:0;background:none;cursor:pointer;color:var(--fg);border-radius:.25rem} - .board-rm button:hover{background:#c0392b;color:#fff} + .board-copy,.board-rm-btn{opacity:0;transition:opacity .12s;flex:0 0 auto} + .board-row:hover .board-copy,.board-row:hover .board-rm-btn, + .board-copy:focus,.board-rm-btn:focus{opacity:1} + .board-rm-btn{font:inherit;font-size:1rem;line-height:1;padding:.1rem .35rem; + border:0;background:none;cursor:pointer;color:var(--fg-2);border-radius:.25rem} + .board-rm-btn:hover{background:var(--aus-red);color:#fff} @media (max-width:600px){ /* No hover on touch — controls must be permanently visible or unreachable. */ - .board-copy,.board-rm button{opacity:1} + .board-copy,.board-rm-btn{opacity:1} .board-meta{display:none} } diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 39e9ad8..5fed6c6 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -33,16 +33,33 @@ it, when) is readable at a glance, which is the whole reason a bare URL three days old is useless. - Removal posts a CONTENT ID, never a row number: another session can append - between this page rendering and the × being clicked, and an index would - then delete a neighbour. #} -
+ ORDER: pinned rows first, then newest-first (order_for_display). Pin a row + with the ★ so the ones you care about stop scrolling off the bottom. + + ONE , not one-per-row: checkboxes drive the bulk delete, while the + per-row × and ★ are submit buttons with their own `formaction`. That keeps + all three actions in a single form (nested forms are invalid HTML) AND lets + every one work with JS off — JS only adds select-all and the live count. + + Every action posts a CONTENT ID, never a row number: another session can + append between this page rendering and a click, and an index would then hit + a neighbour. An id matches the row the operator saw, or nothing. #} + {% set pinned_n = board | selectattr('pinned') | list | length %} +
- {{ board|length }} link{{ '' if board|length == 1 else 's' }} - newest last · appended by any session · × removes one row + + {{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %} + pinned first · newest on top · ★ pins a row · tick rows to delete + +
{% for e in board %} -
+
+ +
{{ e.desc }}
{{ e.url }}
@@ -52,14 +69,12 @@ {% if e.when %}{{ e.when }}{% endif %}
- - - - +
{% endfor %} -
+ {% endif %} {% if not items and not board %} @@ -171,5 +186,62 @@ }); }); })(); + + /* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row + × / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only + adds select-all, a live count, and disabling 🗑 when nothing is ticked. The + per-row × confirm reads desc/url from data-* attributes rather than being + interpolated into an inline handler, so an arbitrary agent-posted description + (quotes, newlines) can never break out into the page's JS. */ + (function () { + var form = document.getElementById('boardform'); + if (!form) return; + var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check')); + var selall = document.getElementById('board-selall'); + var delBtn = document.getElementById('board-del-sel'); + var countEl = document.getElementById('board-selcount'); + + function selected() { return boxes.filter(function (b) { return b.checked; }); } + function refresh() { + var n = selected().length; + if (countEl) countEl.textContent = n; + if (delBtn) delBtn.disabled = n === 0; + if (selall) { + selall.checked = n > 0 && n === boxes.length; + selall.indeterminate = n > 0 && n < boxes.length; + } + } + if (selall) { + selall.addEventListener('change', function () { + boxes.forEach(function (b) { b.checked = selall.checked; }); + refresh(); + }); + } + boxes.forEach(function (b) { b.addEventListener('change', refresh); }); + + // Bulk delete: confirm with the count. Attached to the button (not the form's + // submit) so the per-row × / ★ submits — which share this form — are unaffected. + if (delBtn) { + delBtn.addEventListener('click', function (ev) { + var n = selected().length; + if (n === 0) { ev.preventDefault(); return; } + if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) { + ev.preventDefault(); + } + }); + } + + form.querySelectorAll('.board-rm-btn').forEach(function (btn) { + btn.addEventListener('click', function (ev) { + var d = btn.getAttribute('data-desc') || ''; + var u = btn.getAttribute('data-url') || ''; + if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) { + ev.preventDefault(); + } + }); + }); + + refresh(); + })(); {% endblock %} diff --git a/pyproject.toml b/pyproject.toml index e8f40eb..5e8880b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.7" +version = "0.1.8" description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_booth.py b/tests/test_booth.py index 0261076..fdaddc1 100644 --- a/tests/test_booth.py +++ b/tests/test_booth.py @@ -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