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 0336e033b8
commit 76fdf45925
7 changed files with 472 additions and 37 deletions
+28 -4
View File
@@ -140,6 +140,8 @@ to a safe basename (no path traversal).
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
| `POST /b/<name>/unlink` | Remove ONE row from a link board (form field `entry` = content id) |
| `POST /b/<name>/unlink-many` | Remove SEVERAL rows — the multi-select delete (repeated form field `sel` = content ids) |
| `POST /b/<name>/pin` | Toggle a row's pinned/favorite state (form field `entry` = content id) |
| `DELETE /b/<name>` | 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
+35 -4
View File
@@ -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()
+89
View File
@@ -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) <sub>· who · when</sub>
_LINK_RE = re.compile(
r"^- \[(?P<desc>.*?)\]\((?P<url>[^)]*)\)"
@@ -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"]]
+39 -15
View File
@@ -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}
}
+85 -13
View File
@@ -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. #}
<div class="board">
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 <form>, 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 %}
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
<div class="board-head">
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}</span>
<span class="board-note">newest last · appended by any session · × removes one row</span>
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
<span class="board-spacer"></span>
<button type="submit" class="board-del-sel" id="board-del-sel"
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
</div>
{% for e in board %}
<div class="board-row">
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main">
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
<div class="board-url">{{ e.url }}</div>
@@ -52,14 +69,12 @@
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
</div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
<form class="board-rm" method="post" action="/b/{{ name_url }}/unlink"
onsubmit="return confirm('Remove this link?\n\n{{ e.desc }}\n{{ e.url }}\n\nThe rest of the board is untouched.')">
<input type="hidden" name="entry" value="{{ e.id }}">
<button title="remove this link">×</button>
</form>
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
name="entry" value="{{ e.id }}" title="remove this link"
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
</div>
{% endfor %}
</div>
</form>
{% 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();
})();
</script>
{% endblock %}
+1 -1
View File
@@ -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 = [
+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