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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -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