fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded
In-place client (base.html): - Saves are serialized: POST, re-fetch and swap complete before the next save starts, so an older snapshot can no longer land after a newer one. - A form already queued or in flight ignores another submit; a double-click writes one note. - Dirty controls (drafts, unsent radio choices) and disclosures carry by identity (form action + hidden ask/target/mark/f + name), not position. - Any non-tile structural difference, or a page with no region to swap, reloads instead of patching. Server and templates: - .seen is a JSON array read without following links or blocking, regular files of at most 1 MiB only; malformed, nested-too-deep or planted markers read as nothing seen. - landed_at reads symlinks by lstat and skips one unreadable entry instead of pinning the booth in "new". - The Desk counts flags on current items only; orphan flags are listed under the tray with an unmark form. - Agent-written bench and bookmark URLs link only when http(s). - Audio and video tiles carry a review link. - A rel the filesystem cannot represent is a 404, not a 500. - A non-finite Accept q-value fails to parse. - The standalone marks page has regions and updates in place. - The review's next arrow sits at the edge at phone width. Contract amended for each, plus an accepted-risks section (unlocked .seen read-modify-write, a planted .viewed symlink, Item.ordinal with no default). 741 passed. Each new browser test was mutation-checked against its fix; the serialization test forces the race with a held first refresh, since localhost alone never lost it.
This commit is contained in:
+41
-12
@@ -37,6 +37,8 @@ import asyncio
|
||||
import fcntl
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -258,15 +260,16 @@ def _newest_mtime(path: Path) -> float:
|
||||
|
||||
def _content_mtime(path: Path) -> float:
|
||||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
||||
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||||
files and symlinks (by lstat) with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||||
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
|
||||
content, so none of them may make a booth read as newly landed.
|
||||
|
||||
Files only, never directories: creating `.viewed` bumps the booth
|
||||
directory's own mtime, and counting that would make the first look at a
|
||||
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
|
||||
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
|
||||
booth we cannot read is shown as new rather than hidden as old.
|
||||
booth look like a delivery. An empty booth landed at 0.0. One unreadable
|
||||
ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the
|
||||
posture `_newest_mtime` takes and for a milder reason here: a booth we
|
||||
cannot read is shown as new rather than hidden as old.
|
||||
"""
|
||||
newest = 0.0
|
||||
try:
|
||||
@@ -275,10 +278,15 @@ def _content_mtime(path: Path) -> float:
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
except FileNotFoundError:
|
||||
# lstat: a posted SYMLINK counts by its own mtime — when it was
|
||||
# placed — never by its target's. A link to a busy file outside
|
||||
# the booth must not make the booth read as newly delivered.
|
||||
st = p.lstat()
|
||||
except OSError:
|
||||
# One unreadable entry costs that entry, not the booth: reading
|
||||
# the whole booth as landed NOW would pin it in "new" forever.
|
||||
continue
|
||||
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
|
||||
if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
return time.time()
|
||||
@@ -392,10 +400,13 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
|
||||
try:
|
||||
live = {it.rel for it in items}
|
||||
seen = (read_seen(booth) | {rel}) & live
|
||||
# A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading
|
||||
# space, and the host locale must not decide whether a name encodes.
|
||||
body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape")
|
||||
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write("".join(f"{r}\n" for r in sorted(seen)))
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(body)
|
||||
os.replace(tmp, booth / SEEN_FILE)
|
||||
except BaseException:
|
||||
try:
|
||||
@@ -403,7 +414,9 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError:
|
||||
except (OSError, ValueError):
|
||||
# ValueError covers an encode failure — it is not an OSError, and a
|
||||
# look that cannot be recorded must never cost the page.
|
||||
pass
|
||||
|
||||
|
||||
@@ -433,6 +446,9 @@ def wants_json(accept: str | None) -> bool:
|
||||
key, _, value = param.partition("=")
|
||||
if key.strip().lower() == "q":
|
||||
q = float(value.strip())
|
||||
if not math.isfinite(q):
|
||||
# inf, 1e999, nan parse as floats but are not q-values
|
||||
raise ValueError("non-finite q")
|
||||
if mtype.strip().lower() == "application/json" and q > 0:
|
||||
wanted = True
|
||||
except ValueError:
|
||||
@@ -647,7 +663,9 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# dated question, because the damage is what needs fixing.
|
||||
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
|
||||
else None),
|
||||
"flags": len(flagged_targets(marks)),
|
||||
# items that EXIST: a flag on a file since deleted is shown on
|
||||
# the booth page for withdrawal, not counted as a pick here
|
||||
"flags": len(flagged_targets(marks) & {it.rel for it in items}),
|
||||
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||||
# `landed_at` is content. "New since you looked" reads only the
|
||||
# second, so a flag or a view never makes a booth look new.
|
||||
@@ -1180,7 +1198,16 @@ def create_app(
|
||||
# the wrong group. The rail's jump links do not depend on this.
|
||||
"inline_groups": bool(rail["groups"]) and _contiguous(
|
||||
[it["group"] for it in shown]),
|
||||
# THE flag predicate for this page — the tile class and the tile
|
||||
# toggle read it too, so no surface on the page can disagree.
|
||||
"flagged_set": flagged_targets(marks),
|
||||
"tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
|
||||
# A flag whose file is gone from the booth: no tile to stamp and
|
||||
# no tray slot, so it is listed apart with its withdraw control
|
||||
# rather than vanishing from the page while staying in the file.
|
||||
"orphan_flags": [m for m in marks
|
||||
if m.shape == "flag" and m.error is None and m.target
|
||||
and m.target not in {it["name"] for it in gallery}],
|
||||
"ord_width": len(str(len(gallery))),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
# The same provenance line the index card carries. Deliberate:
|
||||
@@ -1705,7 +1732,9 @@ def create_app(
|
||||
booth = resolve_booth(name)
|
||||
try:
|
||||
target = (booth / f).resolve()
|
||||
except OSError:
|
||||
except (OSError, ValueError):
|
||||
# ValueError: an embedded NUL. Hostile input, like every other
|
||||
# unresolvable `f` — a 404, never a 500.
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
|
||||
+38
-5
@@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
@@ -108,14 +111,44 @@ class Item:
|
||||
SEEN_FILE = ".seen"
|
||||
|
||||
|
||||
# A seen marker bigger than this is not one this service wrote: a JSON array of
|
||||
# every rel in a 270-item booth is a few KB.
|
||||
SEEN_MAX_BYTES = 1 << 20
|
||||
|
||||
|
||||
def read_seen(booth: Path) -> set[str]:
|
||||
"""Rels seen at full size. Missing or unreadable file -> empty set; a
|
||||
damaged marker costs the tape its memory, never the page."""
|
||||
"""Rels seen at full size (R2 C2). A JSON array of strings, because a rel
|
||||
may hold a leading space or a newline and must round-trip exactly.
|
||||
|
||||
NEVER RAISES and NEVER BLOCKS. Any fleet session can write into a booth,
|
||||
so the marker may be planted: it is opened without following a link and
|
||||
without blocking (a FIFO with no writer), refused unless it is a regular
|
||||
file of sane size, and anything unreadable or malformed reads as nothing
|
||||
seen — a damaged marker costs the tape its memory, never the page.
|
||||
"""
|
||||
try:
|
||||
text = (booth / SEEN_FILE).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except OSError:
|
||||
return set()
|
||||
return {ln.strip() for ln in text.splitlines() if ln.strip()}
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_size > SEEN_MAX_BYTES:
|
||||
return set()
|
||||
raw = os.read(fd, SEEN_MAX_BYTES + 1)
|
||||
except OSError:
|
||||
return set()
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, RecursionError):
|
||||
# RecursionError: a deeply nested array (`[[[[...`) blows the parser's
|
||||
# stack, and it is neither a ValueError nor an OSError — the same hole
|
||||
# marks.py, manifest.py and benches.py already close.
|
||||
return set()
|
||||
if not isinstance(data, list):
|
||||
return set()
|
||||
return {r for r in data if isinstance(r, str)}
|
||||
|
||||
|
||||
def read_blurred(booth: Path) -> set[str]:
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
declared R2 change from the click order below — each the original shown
|
||||
small, blurred if the item is. The standalone marks page has no item
|
||||
records, so it keeps the list, in `(created, id)` order. #}
|
||||
{% if tray is defined and tray %}
|
||||
{% if tray is defined %}{% if tray %}
|
||||
{# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
|
||||
a closed <details>, which base.html shows open-and-summary-less above
|
||||
1000px with no script. Below it, the question sits above the set and the
|
||||
@@ -151,7 +151,25 @@
|
||||
</div>
|
||||
</article>
|
||||
</details>
|
||||
{% elif tray is not defined and flags %}
|
||||
{% endif %}
|
||||
{% if orphan_flags %}
|
||||
<article class="mark mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
<span class="mark-id">{{ orphan_flags|length }} on files no longer in this booth</span>
|
||||
</header>
|
||||
<ul class="orphan-flags">
|
||||
{% for m in orphan_flags %}
|
||||
<li><span class="mono">{{ m.target }}</span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button type="submit" class="mark-x" title="withdraw this flag">×</button>
|
||||
</form></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% elif flags %}
|
||||
<article class="mark mark-flags" id="mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
|
||||
+107
-36
@@ -422,6 +422,9 @@
|
||||
font-size:var(--size-micro);text-transform:uppercase;color:var(--text-muted)}
|
||||
.tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
|
||||
font:600 10px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
|
||||
.orphan-flags{margin:0;padding:8px 14px 12px;list-style:none;display:flex;flex-direction:column;gap:4px;
|
||||
font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
|
||||
.orphan-flags li{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.tray-item:hover{text-decoration:none;box-shadow:0 0 0 1px var(--success)}
|
||||
/* ordinals: the item's number in the whole set (C1) */
|
||||
.item > .ord{position:absolute;top:10px;left:10px;z-index:2;padding:3px 6px;border-radius:var(--radius-sm);
|
||||
@@ -484,6 +487,7 @@
|
||||
padding:10px 12px;color:var(--text-muted);font-size:var(--size-caption);font-family:var(--font-mono);
|
||||
border-top:1px solid var(--border-subtle);word-break:break-word}
|
||||
.item figcaption .cap-text{grid-column:2 / -1;font-family:var(--font-sans);font-size:var(--size-sm);line-height:1.5}
|
||||
.item figcaption .rv-link{grid-row:2;grid-column:2;justify-self:start;font-family:var(--font-mono);font-size:var(--size-micro)}
|
||||
.item figcaption .blurtoggle{grid-row:2;grid-column:3}
|
||||
.item figcaption .flagtoggle{grid-row:2;grid-column:4}
|
||||
.item figcaption .dl-link{margin:0}
|
||||
@@ -837,11 +841,47 @@
|
||||
var st = document.querySelector('[data-region="status"]');
|
||||
if (st) { st.textContent = text; st.hidden = false; }
|
||||
}
|
||||
/* A form's IDENTITY: its action plus the hidden fields that say what it is
|
||||
about (which pick, which item, which mark). Stable across renders, where
|
||||
a position inside a region is not — a form that appears or vanishes
|
||||
above another would shift every index after it. */
|
||||
function formKey(f) {
|
||||
if (!f) return '';
|
||||
var id = [];
|
||||
['ask', 'target', 'mark', 'f'].forEach(function (n) {
|
||||
var h = f.querySelector('input[type=hidden][name="' + n + '"]');
|
||||
if (h) id.push(n + '=' + h.value);
|
||||
});
|
||||
return (f.getAttribute('action') || '') + '|' + id.join('&');
|
||||
}
|
||||
function fieldKey(el) {
|
||||
var k = formKey(el.form) + '|' + el.name;
|
||||
return (el.type === 'radio' || el.type === 'checkbox') ? k + '=' + el.value : k;
|
||||
}
|
||||
function detailsKey(d, i, sameClass) {
|
||||
var ask = d.querySelector('input[name="ask"]');
|
||||
if (ask) return 'ask:' + ask.value;
|
||||
var f = d.querySelector('form');
|
||||
if (f) return 'form:' + formKey(f);
|
||||
return 'cls:' + d.className + '#' + sameClass;
|
||||
}
|
||||
function detailsMap(root) {
|
||||
var m = {}, seen = {};
|
||||
root.querySelectorAll('details').forEach(function (d, i) {
|
||||
var n = seen[d.className] = (seen[d.className] || 0) + 1;
|
||||
m[detailsKey(d, i, n)] = d;
|
||||
});
|
||||
return m;
|
||||
}
|
||||
/* What the server cannot render, carried from the old node to the new:
|
||||
live MEDIA elements whose src did not change (a playing track keeps
|
||||
playing, a decoded image does not collapse to zero height and jolt the
|
||||
page), and the per-viewer view state a reload would have reset anyway
|
||||
but an in-place save must not — a revealed blur, a closed doc. */
|
||||
playing, a decoded image does not collapse and jolt the page); the
|
||||
per-viewer view state a reload would have reset but an in-place save
|
||||
must not (a revealed blur, a closed doc, a disclosure the reader
|
||||
opened or closed); and every DIRTY control — a half-typed note, an
|
||||
edited one, a radio picked and not yet sent. All matched by IDENTITY,
|
||||
never by position. The form just sent is the exception: its fields and
|
||||
its disclosure come back as the server rendered them. */
|
||||
function carry(oldEl, newEl, sent) {
|
||||
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
|
||||
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
|
||||
@@ -856,57 +896,75 @@
|
||||
['revealed', 'is-closed'].forEach(function (c) {
|
||||
if (oldEl.classList.contains(c)) newEl.classList.add(c);
|
||||
});
|
||||
/* A disclosure the reader opened or closed stays that way: the server
|
||||
renders its default, the reader's choice is client state. */
|
||||
var newDetails = newEl.querySelectorAll('details');
|
||||
oldEl.querySelectorAll('details').forEach(function (d, i) {
|
||||
/* ...except the one holding the form just sent: an answered pick's
|
||||
form comes back folded on purpose, showing the recorded answer. */
|
||||
var freshDetails = detailsMap(newEl);
|
||||
var oldDetails = detailsMap(oldEl);
|
||||
Object.keys(oldDetails).forEach(function (k) {
|
||||
var d = oldDetails[k];
|
||||
if (sent && d.contains(sent)) return;
|
||||
if (newDetails[i]) newDetails[i].open = d.open;
|
||||
if (freshDetails[k]) freshDetails[k].open = d.open;
|
||||
});
|
||||
/* An unsaved DRAFT survives a swap it was not part of: a note half-typed
|
||||
on one tile must not vanish because a flag landed on another. The
|
||||
form that was just sent is the exception — its field is supposed to
|
||||
come back empty. Matched by name and position within the region. */
|
||||
var fresh = newEl.querySelectorAll('textarea, input[type=text]');
|
||||
oldEl.querySelectorAll('textarea, input[type=text]').forEach(function (f, i) {
|
||||
if (!f.value || (sent && sent.contains(f))) return;
|
||||
var t = fresh[i];
|
||||
if (t && t.name === f.name && !t.value) t.value = f.value;
|
||||
var freshFields = {};
|
||||
newEl.querySelectorAll('textarea, input').forEach(function (el) {
|
||||
if (el.type !== 'hidden') freshFields[fieldKey(el)] = el;
|
||||
});
|
||||
oldEl.querySelectorAll('textarea, input').forEach(function (el) {
|
||||
if (el.type === 'hidden' || (sent && el.form === sent)) return;
|
||||
var t = freshFields[fieldKey(el)];
|
||||
if (!t) return;
|
||||
if (el.type === 'radio' || el.type === 'checkbox') {
|
||||
if (el.checked !== el.defaultChecked) t.checked = el.checked;
|
||||
} else if (el.value !== el.defaultValue) {
|
||||
t.value = el.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* Returns false when the fresh page's STRUCTURE differs from the live one
|
||||
beyond a tile falling out of a filter — a panel region that appeared or
|
||||
vanished — or when the page has no region to swap at all. Then only a
|
||||
reload tells the truth. */
|
||||
function swap(html, sent) {
|
||||
var fresh = new DOMParser().parseFromString(html, 'text/html');
|
||||
document.querySelectorAll('[data-region]').forEach(function (el) {
|
||||
var freshById = {}, liveIds = {};
|
||||
fresh.querySelectorAll('[data-region]').forEach(function (c) {
|
||||
var id = c.getAttribute('data-region');
|
||||
if (!(id in freshById)) freshById[id] = c;
|
||||
});
|
||||
var live = [].slice.call(document.querySelectorAll('[data-region]'));
|
||||
live.forEach(function (el) { liveIds[el.getAttribute('data-region')] = true; });
|
||||
for (var id in freshById) {
|
||||
if (id !== 'status' && !liveIds[id]) return false; /* a region appeared */
|
||||
}
|
||||
var ok = true, swapped = 0;
|
||||
live.forEach(function (el) {
|
||||
var id = el.getAttribute('data-region');
|
||||
if (id === 'status') return;
|
||||
var next = null;
|
||||
fresh.querySelectorAll('[data-region]').forEach(function (cand) {
|
||||
if (!next && cand.getAttribute('data-region') === id) next = cand;
|
||||
});
|
||||
var next = freshById[id];
|
||||
if (next) {
|
||||
var node = document.importNode(next, true);
|
||||
carry(el, node, sent);
|
||||
el.replaceWith(node);
|
||||
} else {
|
||||
/* ABSENT from the fresh page — a tile a filter no longer matches,
|
||||
say, after un-flagging under ?filter=flagged. Left in place, never
|
||||
deleted (deleting would shift every tile after it under the
|
||||
swapped++;
|
||||
} else if (id.indexOf('item-') === 0) {
|
||||
/* A TILE absent from the fresh page — one a filter no longer
|
||||
matches, after un-flagging under ?filter=flagged. Left in place,
|
||||
never deleted (deleting would shift every tile after it under the
|
||||
reader's eye), and marked stale so it does not pass for current.
|
||||
The next navigation drops it. */
|
||||
el.classList.add('is-stale');
|
||||
} else {
|
||||
ok = false; /* a panel vanished */
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('booth:swapped'));
|
||||
return ok && swapped > 0; /* a page with no regions shows nothing in place */
|
||||
}
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target;
|
||||
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
|
||||
ev.preventDefault();
|
||||
var data = new FormData(form);
|
||||
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
|
||||
fetch(form.action, {
|
||||
/* SERIALIZED: each save runs its POST, its re-fetch and its swap before
|
||||
the next begins, so an older snapshot can never land after a newer one.
|
||||
A form already queued or in flight ignores another submit — a
|
||||
double-click writes one note, not two. */
|
||||
var queue = Promise.resolve();
|
||||
function run(form, data) {
|
||||
return fetch(form.action, {
|
||||
method: 'POST', body: new URLSearchParams(data),
|
||||
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
@@ -915,12 +973,25 @@
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error('status ' + r.status);
|
||||
return r.text();
|
||||
}).then(function (html) { swap(html, form); }).catch(function () {
|
||||
}).then(function (html) {
|
||||
if (!swap(html, form)) window.location.reload();
|
||||
}).catch(function () {
|
||||
/* Said, then reloaded after a beat, so the words are readable rather
|
||||
than a flash before the page goes. */
|
||||
say('Could not save in place — reloading to show what was saved.');
|
||||
setTimeout(function () { window.location.reload(); }, 900);
|
||||
});
|
||||
}
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target;
|
||||
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
|
||||
ev.preventDefault();
|
||||
if (form.__busy) return;
|
||||
form.__busy = true;
|
||||
var data = new FormData(form);
|
||||
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
|
||||
queue = queue.then(function () { return run(form, data); })
|
||||
.then(function () { form.__busy = false; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
note field. Same macro discipline as blurtoggle above — three item branches,
|
||||
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||||
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
||||
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
|
||||
{# THE flag predicate (flagged_targets), shared with every other surface #}
|
||||
{% set flagged = it.name in flagged_set %}
|
||||
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ it.name }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
@@ -370,7 +371,7 @@
|
||||
</details>
|
||||
</figure>
|
||||
{% else %}
|
||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if it.name in flagged_set %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||||
{{ ordinal(it) }}
|
||||
{% if it.blurred %}
|
||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||
@@ -406,6 +407,10 @@
|
||||
<figcaption>
|
||||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
||||
{# R2: every MEDIA tile links into the review — a picture through its
|
||||
image, sound and video through this. Enter on the grid cursor
|
||||
follows the first `view` link on the tile. #}
|
||||
{% if it.kind in ('video', 'audio') %}<a class="rv-link" href="view?f={{ it.url }}" title="review at full size">⤢ review</a>{% endif %}
|
||||
{{ blurtoggle(name_url, it) }}
|
||||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||||
</figcaption>
|
||||
@@ -592,5 +597,65 @@
|
||||
}
|
||||
bindTiles();
|
||||
document.addEventListener('booth:swapped', bindTiles);
|
||||
|
||||
/* RESTORED (heid bug-hunt, 2/4): R2's rewrite of the tile handlers above
|
||||
deleted this block with them. Its confirmations guard destructive
|
||||
actions, so it is back verbatim. */
|
||||
/* 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 %}
|
||||
|
||||
@@ -116,11 +116,14 @@
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
|
||||
{% for b in benches %}
|
||||
<a class="desk-bench is-{{ b.state }}" href="{{ b.url }}" target="_blank" rel="noopener">
|
||||
{# Agent-written URLs: only http(s) becomes a link. Autoescape stops markup,
|
||||
not a `javascript:` scheme, so anything else renders as plain text. #}
|
||||
{% set web = b.url.lower().startswith(('http://', 'https://')) %}
|
||||
<{{ 'a' if web else 'div' }} class="desk-bench is-{{ b.state }}"{% if web %} href="{{ b.url }}" target="_blank" rel="noopener"{% endif %}>
|
||||
<span class="desk-bench-dot" aria-hidden="true"></span>
|
||||
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
|
||||
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
|
||||
</a>
|
||||
</{{ 'a' if web else 'div' }}>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -129,8 +132,9 @@
|
||||
<section class="desk-panel" data-panel="bookmarks">
|
||||
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
|
||||
{% for e in bookmarks %}
|
||||
<a class="desk-mark{% if e.pinned %} is-pinned{% endif %}" href="{{ e.url }}" target="_blank" rel="noopener">
|
||||
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</a>
|
||||
{% set web = e.url.lower().startswith(('http://', 'https://')) %}
|
||||
<{{ 'a' if web else 'div' }} class="desk-mark{% if e.pinned %} is-pinned{% endif %}"{% if web %} href="{{ e.url }}" target="_blank" rel="noopener"{% endif %}>
|
||||
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</{{ 'a' if web else 'div' }}>
|
||||
{% endfor %}
|
||||
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
|
||||
</section>
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
|
||||
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
|
||||
read a half-answered pick as closed. #}
|
||||
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span>
|
||||
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span></span>
|
||||
</div>
|
||||
{# One region around both branches, so answering the last mark away swaps in
|
||||
the empty state instead of reading as a structural change. #}
|
||||
<div class="marks-panel" data-region="marks-panel">
|
||||
{% if marks %}
|
||||
{% include "_marks.html" %}
|
||||
{% else %}
|
||||
<div class="empty">This booth has no marks.</div>
|
||||
{% include "_marks.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -138,7 +138,12 @@
|
||||
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
|
||||
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
|
||||
color:oklch(0.91 0.008 216)}
|
||||
.vprev{left:0}.vnext{right:360px}
|
||||
/* The next arrow clears the 360px verdict rail only while the rail sits
|
||||
beside the stage. Scoped to the wide layout: stated bare, this rule came
|
||||
later in the page than base.html's narrow override and silently won it,
|
||||
parking the arrow 360px in from the edge of a phone. */
|
||||
.vprev{left:0}.vnext{right:0}
|
||||
@media (min-width:901px){.vnext{right:360px}}
|
||||
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
|
||||
color:var(--text-body);white-space:pre-wrap}
|
||||
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
||||
|
||||
@@ -102,7 +102,9 @@ no route derives it.
|
||||
- It is a **declared change** to the zoom-ring rule. Today's ring is images
|
||||
only. A booth mixing images and audio now rings through both, in set order.
|
||||
- `image_chain` stays for its callers and tests.
|
||||
- **`SEEN_FILE = ".seen"`**: one rel per line, same shape as `.blurred`.
|
||||
- **`SEEN_FILE = ".seen"`**: a UTF-8 JSON array of rels. Not one rel per
|
||||
line, `.blurred`'s shape: a file name may contain a newline, and a line format
|
||||
would split one such rel into two, neither of them real.
|
||||
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
|
||||
and gated on the item record — the same gate `record_view` has.
|
||||
- Each write rewrites the whole file: the previous set plus `rel`, minus
|
||||
@@ -120,7 +122,12 @@ no route derives it.
|
||||
items.
|
||||
- NEVER RAISES, like `record_view`: failing to record a look costs the
|
||||
marker, not the page.
|
||||
- `read_seen(booth) -> set[str]` is lenient, like `read_blurred`.
|
||||
- `read_seen(booth) -> set[str]` is lenient and NEVER RAISES. It opens without
|
||||
following a symlink and without blocking, reads only a regular file of at
|
||||
most 1 MiB, and keeps only the array's string members. Anything else — a
|
||||
link, a FIFO, a directory, an oversized, malformed or too-deeply-nested
|
||||
file — reads as the empty set. `.seen` sits in an agent-writable directory, and a planted FIFO
|
||||
must not hang the review route.
|
||||
- `items` is the route's own `booth_items` result. It is passed in so that the
|
||||
prune ("minus rels no longer in `booth_items`") costs no second walk.
|
||||
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
|
||||
@@ -142,7 +149,8 @@ exactly `application/json` and whose q-value is absent or greater than 0.
|
||||
- A near miss such as `application/jsonx` → False.
|
||||
- **Every entry is parsed before anything is decided.** One unparseable
|
||||
entry anywhere, before or after a good one, makes the whole header False.
|
||||
- Any header that fails to parse → False.
|
||||
- Any header that fails to parse → False. A q-value that is not a finite
|
||||
number (`q=nan`, `q=inf`) fails to parse.
|
||||
- **It fails toward the 303.**
|
||||
|
||||
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
|
||||
@@ -177,24 +185,42 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
|
||||
counts change when you flag), and the header's open count and lifetime
|
||||
line (`booth-status`).
|
||||
- On a booth with marks but no set: the panel (`marks-panel`).
|
||||
- On the standalone marks page: the header's open count (`booth-status`)
|
||||
and the panel (`marks-panel`), one region around both its states so
|
||||
answering the last mark away swaps in the empty state.
|
||||
- On the review: the rail, the filmstrip and the tape.
|
||||
- The stage is never a region: replacing it would restart a playing video
|
||||
or audio track.
|
||||
- A region absent from the response is left alone and never deleted.
|
||||
Deleting it would shift every tile after it under the reader's eye. It
|
||||
is marked `is-stale` so it does not pass for current: un-flagging under
|
||||
`?filter=flagged` is the case. The next navigation drops it.
|
||||
- A TILE (`item-*`) absent from the response is left alone and never
|
||||
deleted. Deleting it would shift every tile after it under the reader's
|
||||
eye. It is marked `is-stale` so it does not pass for current:
|
||||
un-flagging under `?filter=flagged` is the case. The next navigation
|
||||
drops it.
|
||||
- Any OTHER difference in structure — a non-tile region in the response
|
||||
that the page lacks, or one the page has that the response lacks — or a
|
||||
page with no region to swap at all, is not patched: the script reloads
|
||||
with a GET, so what you see is the server's truth.
|
||||
- The swap also carries the per-viewer state a reload would have reset
|
||||
but an in-place save must not:
|
||||
- live media whose src is unchanged;
|
||||
- a revealed blur;
|
||||
- a closed doc;
|
||||
- disclosures the reader opened or closed;
|
||||
- unsaved drafts.
|
||||
- every DIRTY control: a half-typed or edited note, a radio picked and
|
||||
not yet sent.
|
||||
|
||||
The form just sent is the exception: its field comes back empty, and its
|
||||
All of it is matched by IDENTITY, never by position: a form by its
|
||||
action and its hidden `ask`/`target`/`mark`/`f` fields, a control by its
|
||||
form plus its name (plus its value for a radio or checkbox), a disclosure
|
||||
by the pick or form it holds. A flag that adds a tray row above a draft
|
||||
must not move the draft into the wrong box. The form just sent is the
|
||||
exception: its fields come back as the server rendered them, and its
|
||||
disclosure comes back folded.
|
||||
3. **The script never re-POSTs.** A retry after a lost response would re-apply
|
||||
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
|
||||
before the next begins, so an older snapshot never lands after a newer one
|
||||
(three quick flags show three flags). A form already queued or in flight
|
||||
ignores another submit: a double-click writes one note, not two.
|
||||
4. **The script never re-POSTs.** A retry after a lost response would re-apply
|
||||
the judgment: a duplicate note, or a re-dated answer.
|
||||
- On a non-204 HTTP response, or a network failure, it writes a fixed
|
||||
message into the page's server-rendered status element
|
||||
@@ -218,20 +244,29 @@ rule — a second renderer in JavaScript would be the same bug in a new language
|
||||
legacy-import stamp, sort wrong as text.
|
||||
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
|
||||
tie.
|
||||
- **`flags`**: the number of items carrying a READABLE flag mark, shown on
|
||||
every Desk row that has any. `flagged_targets(marks)` is the ONE flag
|
||||
- **`flags`**: the number of CURRENT items carrying a READABLE flag mark,
|
||||
shown on every Desk row that has any — `flagged_targets(marks)` intersected
|
||||
with the booth's item rels. `flagged_targets(marks)` is the ONE flag
|
||||
predicate. The Desk, the tray, the filmstrip, the tape and the review button
|
||||
all read it, and an unreadable flag entry counts nowhere.
|
||||
- **`landed_at`**: the newest mtime among the booth's CONTENT — its REGULAR
|
||||
FILES with no dot-component in their path. **Deliberately not
|
||||
`_newest_mtime`** (INV-5). Three refinements, each load-bearing:
|
||||
all read it, and an unreadable flag entry counts nowhere. A flag whose file
|
||||
has since been deleted is an ORPHAN: it counts on no Desk row, and the tray
|
||||
lists it (C5) so it can be cleared.
|
||||
- **`landed_at`**: the newest mtime among the booth's CONTENT — its regular
|
||||
files and symlinks with no dot-component in their path, each read by
|
||||
`lstat`. **Deliberately not `_newest_mtime`** (INV-5). Five refinements,
|
||||
each load-bearing:
|
||||
- **Files only, never directories.** Creating any dotfile (`.viewed`, the
|
||||
marks file's temp-and-replace) bumps the booth directory's own mtime, so
|
||||
counting directories would make the flag you set after looking read as a
|
||||
delivery.
|
||||
- **A symlink counts by its OWN mtime** — when it was placed — never its
|
||||
target's. A link to a busy file outside the booth must not make the booth
|
||||
read as newly delivered.
|
||||
- **An empty booth landed at 0.0.**
|
||||
- **An unreadable booth reads as NOW.** It is shown as new rather than
|
||||
hidden as old.
|
||||
- **One unreadable entry is skipped.** Reading the whole booth as landed NOW
|
||||
for one bad entry would pin it in 'new' forever.
|
||||
- **A booth whose walk cannot run at all reads as NOW.** It is shown as new
|
||||
rather than hidden as old.
|
||||
- **`viewed_at`**: the mtime of `.viewed`, or None.
|
||||
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
|
||||
order. A blurred one renders blurred, the same rule as the cover.
|
||||
@@ -270,6 +305,9 @@ The side column holds:
|
||||
existing order. Its error return renders as an error line, never as an empty
|
||||
list. This is the booth page's rule: damaged and absent must not render the
|
||||
same.
|
||||
- **Agent-written URLs become links only when they are `http(s)`.** A bench
|
||||
URL or a bookmark with any other scheme renders as plain text. Autoescape
|
||||
stops markup, not a `javascript:` href.
|
||||
- **Bookmarks** come from the board the CLI writes: the booth named by
|
||||
`BOOTH_LINKS_BOARD`, default `links`. They are read through the same
|
||||
never-raising path as `_board_rows`, which gets factored so both callers
|
||||
@@ -312,6 +350,9 @@ unchanged) remain on every row.
|
||||
displayed small (no generated thumbnail), blurred if the item is blurred,
|
||||
with its #.
|
||||
The order is total with no tie-break, because rels are unique.
|
||||
- **Orphan flags** — flags whose target is no longer an item — follow the
|
||||
tray, by target, each with its unmark form. A flag the page cannot show
|
||||
must still be clearable, or it counts in the rail forever.
|
||||
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
|
||||
and base.html's `--rail-h` script both read it), same filter hrefs, same
|
||||
group anchors. When `rail.groups` is non-empty AND every group is one
|
||||
@@ -327,11 +368,17 @@ unchanged) remain on every row.
|
||||
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
|
||||
Each tile is `data-region="item-<url>"`, so the in-place script can replace
|
||||
exactly the tile it flagged.
|
||||
- **An audio or video tile carries a `review` link** to its review page. On
|
||||
those tiles a click drives the player, so without the link the review is
|
||||
reachable only by key.
|
||||
|
||||
### C6 — the review (view.html, booth_view_file)
|
||||
|
||||
This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
|
||||
A requested rel the filesystem cannot represent (a NUL byte, an over-long
|
||||
path) is a 404, as any other unknown rel is — never a 500.
|
||||
|
||||
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
|
||||
- The toggle and its script are rendered and bound only when the stage is
|
||||
an `<img>`.
|
||||
@@ -454,6 +501,22 @@ checked:
|
||||
- `Wipe now` stays in the booth header;
|
||||
- `class="boothhead"` stays.
|
||||
|
||||
## Accepted risks (named, not fixed)
|
||||
|
||||
- **`.seen` is read-modify-write without a lock.** Two reviews of the same
|
||||
booth racing can drop one rel from `.seen`. The cost is cosmetic — a frame
|
||||
shown unseen on the tape — and the next look repairs it; a lock would buy a
|
||||
cosmetic count at the price of a lock file the lifetime clock must ignore.
|
||||
- **A `.viewed` symlink planted by an agent freezes 'new'.** `viewed_at`
|
||||
reads it by `lstat`, and `record_view` refuses to write through it
|
||||
(`O_NOFOLLOW`), so the marker never moves again: once content lands after
|
||||
it, the booth reads as 'new' however often it is opened. It fails in the
|
||||
visible direction — shown, never hidden — and needs write access to the
|
||||
booth, which already buys worse. The remedy is deleting the link.
|
||||
- **`Item` gains `ordinal` with no default.** `booth_items` is the single
|
||||
construction site, keyword-only; a default would let a second site forget
|
||||
it silently (INV-1).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Compare (r3).
|
||||
|
||||
+125
-1
@@ -113,7 +113,7 @@ def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path):
|
||||
for f in ("a.png", "c.png", "a.png", ".marks.lock"):
|
||||
c.get(f"/b/g/view?f={f}")
|
||||
assert read_seen(b) == {"a.png", "c.png"}
|
||||
assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated
|
||||
assert (b / ".seen").read_text() == '["a.png", "c.png"]' # sorted, deduplicated, JSON
|
||||
|
||||
|
||||
def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path):
|
||||
@@ -673,3 +673,127 @@ def test_a_full_size_look_also_counts_as_looking_at_the_booth(tmp_path):
|
||||
assert not (b / ".viewed").exists()
|
||||
_client(tmp_path).get("/b/g/view?f=a.png")
|
||||
assert (b / ".viewed").exists() and (b / ".seen").exists()
|
||||
|
||||
|
||||
def test_a_flag_on_a_file_that_is_gone_stays_visible_and_withdrawable(tmp_path):
|
||||
"""Nyx N3 (2/4): the tray only shows live items, and `tray` being always
|
||||
defined killed the old list fallback — so a flag whose file was deleted
|
||||
rendered NOWHERE on the booth page while the Desk still counted it. It is
|
||||
now listed apart, with its withdraw control; the Desk counts live items."""
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
|
||||
set_flag(b, "a.png", True)
|
||||
set_flag(b, "b.png", True)
|
||||
(b / "b.png").unlink()
|
||||
c = _client(tmp_path)
|
||||
aside = _region(c.get("/b/g/").text, "verdict")
|
||||
orphans = re.search(r'class="orphan-flags".*?</ul>', aside, re.S)
|
||||
assert orphans and "b.png" in orphans.group(0)
|
||||
assert 'action="/b/g/unmark"' in orphans.group(0)
|
||||
row = re.search(r'data-booth="g".*?</article>', c.get("/").text, re.S).group(0)
|
||||
assert "1 flagged" in row
|
||||
|
||||
|
||||
def test_a_planted_fifo_or_device_seen_marker_cannot_hang_the_review(tmp_path):
|
||||
"""Nyx N4 (3/4): `.seen` was read with an unbounded, symlink-following
|
||||
read_text(). A FIFO with no writer blocked the worker forever; a symlink to
|
||||
/dev/zero read until memory ran out. The read now refuses anything that is
|
||||
not a small regular file, without following a link."""
|
||||
import os
|
||||
import threading
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
os.mkfifo(b / ".seen")
|
||||
out = {}
|
||||
t = threading.Thread(target=lambda: out.setdefault(
|
||||
"r", _client(tmp_path).get("/b/g/view?f=a.png")), daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=5)
|
||||
assert "r" in out, "the review hung on a FIFO .seen"
|
||||
assert out["r"].status_code == 200
|
||||
h = _booth(tmp_path, "h", {"a.png": PNG})
|
||||
(h / ".seen").symlink_to("/dev/zero")
|
||||
assert _client(tmp_path).get("/b/h/view?f=a.png").status_code == 200
|
||||
|
||||
|
||||
def test_seen_round_trips_names_with_spaces_and_newlines(tmp_path):
|
||||
"""Nyx (groa, hulda): one stripped line per rel lost ` a.png` and split a
|
||||
name holding a newline into two identities. The marker is a JSON array."""
|
||||
from booth.items import read_seen
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG, " a.png": PNG, "x\ny.png": PNG})
|
||||
c = _client(tmp_path)
|
||||
c.get("/b/g/view", params={"f": " a.png"})
|
||||
c.get("/b/g/view", params={"f": "x\ny.png"})
|
||||
assert read_seen(b) == {" a.png", "x\ny.png"}
|
||||
|
||||
|
||||
|
||||
def test_a_deeply_nested_seen_marker_reads_as_nothing_seen(tmp_path):
|
||||
"""A JSON array nested past the parser's recursion limit raises
|
||||
RecursionError, which is not a ValueError: a 100 KB file of `[` planted as
|
||||
`.seen` escaped the never-raises read and 500'd every review of the booth.
|
||||
It reads as nothing seen, and the next look rewrites it."""
|
||||
from booth.items import read_seen
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
(b / ".seen").write_text("[" * 100_000)
|
||||
assert read_seen(b) == set()
|
||||
assert _client(tmp_path).get("/b/g/view?f=a.png").status_code == 200
|
||||
assert read_seen(b) == {"a.png"}
|
||||
|
||||
def test_the_content_clock_reads_the_booth_not_what_its_links_point_at(tmp_path):
|
||||
"""Nyx (groa, regin): stat() followed a symlink, so a link to a busy file
|
||||
outside the booth made the booth read as newly delivered on every load; and
|
||||
one unreadable entry (a symlink loop) made the whole booth read as landed
|
||||
NOW, forever. The link's own mtime counts; an unreadable entry is skipped."""
|
||||
import os
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
outside = tmp_path / "busy.log"
|
||||
outside.write_text("x")
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
(b / "linked.png").symlink_to(outside)
|
||||
(b / "loop.png").symlink_to(b / "loop.png")
|
||||
for p in (b / "a.png", b / "linked.png", b / "loop.png"):
|
||||
os.utime(p, (t0, t0), follow_symlinks=False)
|
||||
_at(b, t0)
|
||||
c = _client(tmp_path)
|
||||
c.get("/b/g/") # look at it
|
||||
os.utime(outside, None) # the outside file keeps moving
|
||||
assert _desk(c.get("/").text).get("rest") == ["g"]
|
||||
|
||||
|
||||
def test_a_nul_in_the_review_path_is_a_404_not_a_500(tmp_path):
|
||||
"""Nyx (groa, seat-probed): Path raises ValueError on an embedded NUL, and
|
||||
the route caught only OSError. Every other hostile `f` is a 404."""
|
||||
_booth(tmp_path, "g", {"a.png": PNG})
|
||||
assert _client(tmp_path).get("/b/g/view?f=a%00.png").status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("q", ["inf", "1e999", "nan", "-inf"])
|
||||
def test_a_non_finite_q_is_malformed(q):
|
||||
"""Nyx (regin): float() parses inf and 1e999, and inf > 0 — a malformed
|
||||
header slipped through to the 204. Non-finite q is malformed: False."""
|
||||
from booth.app import wants_json
|
||||
assert wants_json(f"application/json;q={q}") is False
|
||||
|
||||
|
||||
def test_a_sound_only_booth_can_open_the_review(tmp_path):
|
||||
"""Nyx (groa): only the image tile linked to view?f=, so a booth of tracks
|
||||
had no way into the review, the tape or `.seen`. Every media tile links in
|
||||
(and Enter on the grid cursor follows that link)."""
|
||||
_booth(tmp_path, "g", {"a.mp3": b"ID3", "b.webm": b"\x1aE"})
|
||||
body = _client(tmp_path).get("/b/g/").text
|
||||
for rel in ("a.mp3", "b.webm"):
|
||||
fig = re.search(r'<figure[^>]*data-item="%s".*?</figure>' % re.escape(rel), body, re.S).group(0)
|
||||
assert f'href="view?f={rel}"' in fig, rel
|
||||
|
||||
|
||||
def test_the_desk_never_makes_a_non_web_url_clickable(tmp_path):
|
||||
"""Nyx (kimi): bookmark and bench URLs are agent-written and land in href.
|
||||
Autoescape does nothing about a `javascript:` scheme. The Desk links only
|
||||
http(s) and shows anything else as plain text."""
|
||||
rows = (_link("evil", "javascript:alert`1`")
|
||||
+ _link("fine", "https://example.test/"))
|
||||
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
|
||||
(board / ".forever").write_bytes(b"")
|
||||
body = _client(tmp_path).get("/").text
|
||||
panel = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
|
||||
assert 'href="javascript:' not in panel
|
||||
assert 'href="https://example.test/"' in panel and "evil" in panel
|
||||
|
||||
@@ -250,3 +250,127 @@ def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(bro
|
||||
page.locator(".verdict summary.v-fold-head").first.click()
|
||||
assert tray.is_visible()
|
||||
ctx.close()
|
||||
|
||||
|
||||
# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------
|
||||
|
||||
def test_the_link_board_still_confirms_before_removing_a_row(browser, live):
|
||||
"""Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's
|
||||
multi-select + confirmation script along with the handlers it replaced.
|
||||
Removing a row is destructive; the confirm naming it must still stand in
|
||||
front of the POST, and select-all must still select."""
|
||||
base, root = live
|
||||
board = root / "links"
|
||||
board.mkdir()
|
||||
(board / "links.md").write_text(
|
||||
"- [one](http://x/1) <sub>· a · 2026-09-01 10:00</sub>\n"
|
||||
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\n")
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/links/", wait_until="networkidle")
|
||||
dialogs = []
|
||||
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
|
||||
page.locator(".board-rm-btn").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator("#board-selall").check()
|
||||
ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length")
|
||||
page.close()
|
||||
assert dialogs and "Remove this link?" in dialogs[0]
|
||||
assert ticked == 2
|
||||
assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing"
|
||||
|
||||
|
||||
def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live):
|
||||
"""Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any
|
||||
other in-place save, drafts were matched by POSITION, and a double-click on
|
||||
Add note wrote two notes. Now: dirty controls carry by identity, and a form
|
||||
already in flight ignores a second submit."""
|
||||
from booth.marks import declare_pick, marks_for
|
||||
base, root = live
|
||||
b = _set(root, 3)
|
||||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||
page.locator('.verdict input[type=radio][value="y"]').check()
|
||||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||||
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
|
||||
assert page.locator('.verdict input[type=radio][value="y"]').is_checked()
|
||||
|
||||
page.locator(".verdict .mark-add textarea").fill("once")
|
||||
page.locator(".verdict .mark-add button").dblclick()
|
||||
page.wait_for_timeout(1500)
|
||||
page.close()
|
||||
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"]
|
||||
|
||||
|
||||
# The first page refresh after a save is held back 1s in the CLIENT: the server
|
||||
# renders it at once (so it carries only the first flag) and the browser sees
|
||||
# it late. Localhost alone never loses that race, so without the hold the test
|
||||
# passed with sequencing deleted — it has to be forced to be a control.
|
||||
_HOLD_FIRST_REFRESH = """
|
||||
(function () {
|
||||
var real = window.fetch, n = 0;
|
||||
window.fetch = function (u, o) {
|
||||
var p = real.apply(this, arguments);
|
||||
if ((!o || !o.method || o.method === 'GET') && n++ === 0) {
|
||||
return p.then(function (r) {
|
||||
return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); });
|
||||
});
|
||||
}
|
||||
return p;
|
||||
};
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def test_quick_successive_flags_all_show(browser, live):
|
||||
"""Nyx (hulda): with no sequencing, an older refresh landing after a newer
|
||||
one showed the newer flag as gone. Saves are serialized."""
|
||||
base, root = live
|
||||
_set(root, 4)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
page.add_init_script(_HOLD_FIRST_REFRESH)
|
||||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||
for rel in ("01.png", "02.png", "03.png"):
|
||||
page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click()
|
||||
page.wait_for_timeout(150)
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000)
|
||||
page.wait_for_timeout(1500)
|
||||
n = page.locator("figure.item.is-flagged").count()
|
||||
page.close()
|
||||
assert n == 3
|
||||
|
||||
|
||||
def test_the_standalone_marks_page_updates_in_place(browser, live):
|
||||
"""Nyx (kimi): the marks page's forms are in-place, but the page had no
|
||||
region, so an answer saved and the page never showed it."""
|
||||
from booth.marks import declare_pick
|
||||
base, root = live
|
||||
b = _set(root, 1)
|
||||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||
page.evaluate("window.__same_page = 1")
|
||||
page.locator('input[type=radio][value="x"]').check()
|
||||
page.locator(".mark-submit").click()
|
||||
page.wait_for_selector(".mark.is-answered", timeout=10000)
|
||||
# In place, not the reload fallback: the page's own window survived.
|
||||
same = page.evaluate("window.__same_page === 1")
|
||||
page.close()
|
||||
assert same
|
||||
|
||||
|
||||
def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
||||
"""Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
|
||||
base.html's narrow override and won it, parking the arrow 360px in from
|
||||
the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
rights = {}
|
||||
for w in (1400, 390):
|
||||
page = browser.new_page(viewport={"width": w, "height": 900})
|
||||
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
|
||||
rights[w] = page.evaluate(
|
||||
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
|
||||
page.close()
|
||||
assert rights == {1400: "360px", 390: "0px"}
|
||||
|
||||
Reference in New Issue
Block a user