diff --git a/booth/app.py b/booth/app.py index 4f59e32..de7be88 100644 --- a/booth/app.py +++ b/booth/app.py @@ -53,44 +53,42 @@ except ImportError: # optional dep — .md then degrades to a plain-text view TEMPLATES_DIR = Path(__file__).parent / "templates" -# Browser-playable media buckets. Anything else renders as a download link. -IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"} -VIDEO_EXTS = {".webm", ".mp4", ".ogv", ".m4v", ".mov"} -AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"} +# The item record lives in booth/items.py — ONE resolver every surface reads. +# These names are RE-EXPORTED rather than merely moved: 22 existing test sites +# import them from booth.app by name, and a silent drop would be found by a +# consumer instead of by us. `test_app_still_exports_the_moved_names` asserts it. +from booth.items import ( # noqa: E402,F401 + AUDIO_EXTS, + BLUR_FILE, + CAPTION_MAX, + DOC_MAX_BYTES, + IMAGE_EXTS, + MARKDOWN_EXTS, + TEXT_EXTS, + VIDEO_EXTS, + Item, + booth_items, + classify, + doc_kind, + find_item, + image_chain, + read_blurred, + render_doc, + render_doc_body, +) -CAPTION_MAX = 800 # chars of a sidecar .txt caption we render - -# Loose text docs that render as a readable in-booth page (not a download). -MARKDOWN_EXTS = {".md", ".markdown", ".mdown"} -TEXT_EXTS = {".txt", ".text", ".log"} -DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered - -# Sentinel dotfile that exempts a booth from the TTL sweep — see the "kept -# booths" note in the module docstring. A dotfile because the existing listing -# code already skips dotfiles, so it costs nothing in item counts or galleries, -# and because `touch`/`rm` is the entire user interface: no flag to remember, no -# state anywhere but the filesystem. +# Sentinel dotfile that exempts a booth from the TTL sweep. A dotfile because +# the existing listing code already skips dotfiles, so it costs nothing in item +# counts or galleries, and because `touch`/`rm` is the entire user interface: no +# flag to remember, no state anywhere but the filesystem. KEEP_MARKER = ".forever" -# Per-item blur state: one relative item path per line, like .pins is one id per -# line. Filesystem IS the state here, same as everything else in this service. -# # ⚠⚠ BLUR IS COSMETIC, NOT ACCESS CONTROL. The file is still served at its own # URL, still in the zip, still on disk. This hides an item from a glance — a # shoulder, a screen-share, a scroll past something you did not want to see # full-size — and nothing more. The Booth has no auth by design; if a thing # must not be seen by whoever can reach port 8090, it must not be in a booth. # Anyone who reads this marker as protection has misread it. -BLUR_FILE = ".blurred" - - -def read_blurred(booth: Path) -> set[str]: - """Blurred item paths for a booth. Missing file -> empty set.""" - try: - text = (booth / BLUR_FILE).read_text() - except (OSError, UnicodeDecodeError): - return set() - return {ln.strip() for ln in text.splitlines() if ln.strip()} def set_blurred(booth: Path, rel: str, on: bool) -> set[str]: @@ -143,46 +141,6 @@ from booth.links import ( # noqa: E402 ) -def doc_kind(name: str) -> str | None: - """'markdown' | 'text' | None — a booth file viewable as a readable page.""" - ext = Path(name).suffix.lower() - if ext in MARKDOWN_EXTS: - return "markdown" - if ext in TEXT_EXTS: - return "text" - return None - - -def render_doc(text: str, kind: str) -> tuple[str, bool]: - """(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists); - plain text — or markdown when the lib is unavailable — → raw text for
."""
- if kind == "markdown" and _markdown is not None:
- html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
- return html, True
- return text, False
-
-
-def booth_image_names(child: Path) -> list[str]:
- """Image files in a booth, in gallery (sorted-rel) order — for viewer prev/next."""
- return sorted(
- p.relative_to(child).as_posix()
- for p in child.rglob("*")
- if p.is_file() and not p.name.startswith(".") and classify(p.name) == "image"
- )
-
-
-def classify(name: str) -> str:
- """image | video | audio | other, by extension."""
- ext = Path(name).suffix.lower()
- if ext in IMAGE_EXTS:
- return "image"
- if ext in VIDEO_EXTS:
- return "video"
- if ext in AUDIO_EXTS:
- return "audio"
- return "other"
-
-
def human_dur(seconds: float) -> str:
s = int(seconds)
if s <= 0:
@@ -263,6 +221,17 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[dict]:
+ """One card per booth for the index.
+
+ Counts and the cover come from `booth_items`, so the index agrees with the
+ booth page about what an item IS. It did not before: this function counted
+ every non-dot, non-ask file, which meant a caption sidecar was counted as an
+ item here and (correctly) not counted there — an A/B pair with two captions
+ read as "4 items" on the index and showed two tiles when you opened it.
+
+ Doc bodies are deliberately NOT rendered — see `booth_items`. The index
+ touches every booth on every page load.
+ """
now = time.time() if now is None else now
booths: list[dict] = []
if not data_dir.is_dir():
@@ -270,34 +239,31 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
for child in data_dir.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
- files = [
- p for p in child.rglob("*")
- if p.is_file() and not p.name.startswith(".")
- and not is_ask_file(p.name) and not is_answer_file(p.name)
- ]
+ items = booth_items(child)
# Asks are questions, not items: counted separately so the index can
# flag a booth that is waiting on the operator.
asks = list_asks(child)
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None
- for f in files:
- k = classify(f.name)
- kinds[k] += 1
- if k == "image" and thumb_url is None:
- thumb_url = quote(f.relative_to(child).as_posix(), safe="/")
+ thumb_blurred = False
+ for it in items:
+ kinds[it.kind] += 1
+ if it.kind == "image" and thumb_url is None:
+ thumb_url = it.url
+ # If the cover image is blurred inside the booth, blur it on the
+ # index too — otherwise the front page cheerfully displays the
+ # exact thing someone asked to hide. Read off the record now,
+ # rather than re-opening .blurred here.
+ thumb_blurred = it.blurred
mtime = _newest_mtime(child)
booths.append(
{
"name": child.name,
"name_url": quote(child.name, safe=""),
- "count": len(files),
+ "count": len(items),
"kinds": kinds,
"thumb_url": thumb_url,
- # If the cover image is blurred inside the booth, blur it on the
- # index too — otherwise the front page cheerfully displays the
- # exact thing someone asked to hide.
- "thumb_blurred": thumb_url is not None
- and unquote(thumb_url) in read_blurred(child),
+ "thumb_blurred": thumb_blurred,
"has_index": (child / "index.html").is_file(),
"uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child),
@@ -312,84 +278,31 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
def build_gallery(child: Path) -> list[dict]:
- """Files in a booth as render items, with caption sidecars folded in.
+ """The gallery's render dicts — a thin adapter over `booth_items`.
- A `.txt` (e.g. `a.png.txt`) or a same-stem `.txt` (e.g. `a.txt`
- next to `a.png`) is consumed as that item's caption rather than shown itself —
- the natural way to label an A/B pair.
+ The resolver owns every fact about an item; this only shapes them for the
+ template and pulls doc bodies for the one surface that inlines them. Kept as
+ a function (rather than inlined at the call site) because the existing test
+ suite reaches for it by name in nine places.
"""
- all_files = [
- p for p in child.rglob("*")
- if p.is_file() and not p.name.startswith(".")
- # `*.ask.json` / `*.answer.json` render as the asks panel, not as tiles
- and not is_ask_file(p.name) and not is_answer_file(p.name)
- ]
- by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
- caption: dict[str, str] = {}
- sidecars: set[str] = set()
-
- for rel, p in by_rel.items():
- if not rel.lower().endswith(".txt"):
- continue
- target = None
- base_full = rel[:-4] # strip ".txt" -> "a.png.txt" => "a.png"
- if base_full in by_rel:
- target = base_full
- else: # "a.txt" beside "a.png"
- parent = str(Path(rel).parent)
- stem = Path(rel).stem
- for q_rel, q in by_rel.items():
- if q_rel == rel:
- continue
- if (
- str(Path(q_rel).parent) == parent
- and Path(q_rel).stem == stem
- and classify(q.name) != "other"
- ):
- target = q_rel
- break
- if target is not None:
- try:
- caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
- except OSError:
- pass
- sidecars.add(rel)
-
- blurred = read_blurred(child)
- items = []
- for rel in sorted(by_rel):
- if rel in sidecars:
- continue
- p = by_rel[rel]
- dkind = doc_kind(p.name)
- rendered = None
- rendered_html = False
- # Pre-render docs so the gallery can show them INLINE (collapsible)
- # instead of linking out to a separate page. Bounded by DOC_MAX_BYTES:
- # a giant log stays a download link rather than being inlined into every
- # index render. Markdown → HTML (marked safe in the template); plain text
- # is returned RAW and the template escapes it inside — pre-escaping
- # here would double-encode under Jinja autoescape.
- if dkind is not None:
- try:
- if p.stat().st_size <= DOC_MAX_BYTES:
- text = p.read_text(errors="replace")
- rendered, rendered_html = render_doc(text, dkind)
- except OSError:
- rendered = None
- items.append(
+ out = []
+ for it in booth_items(child):
+ body = render_doc_body(child, it)
+ rendered, rendered_html = body if body is not None else (None, False)
+ out.append(
{
- "name": rel,
- "kind": classify(p.name),
- "doc": dkind,
- "url": quote(rel, safe="/"),
- "caption": caption.get(rel),
+ "name": it.rel,
+ "kind": it.kind,
+ "doc": it.doc,
+ "url": it.url,
+ "section": it.section,
+ "caption": it.caption,
"rendered": rendered,
"rendered_html": rendered_html,
- "blurred": rel in blurred,
+ "blurred": it.blurred,
}
)
- return items
+ return out
def zip_booth(booth: Path) -> bytes:
@@ -855,6 +768,14 @@ def create_app(
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
+ """Full-size view of ONE item — image zoom, or a doc as a readable page.
+
+ Reads the item's RECORD rather than re-deriving it. That is the whole
+ point of U1: this route used to call classify/doc_kind/render_doc and
+ booth_image_names itself, deriving a strictly smaller set of facts than
+ the gallery did, and the fact it lacked was the caption. An annotated
+ image lost its annotation at exactly the size where it is most readable.
+ """
booth = resolve_booth(name)
try:
target = (booth / f).resolve()
@@ -862,11 +783,24 @@ def create_app(
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")
- file_url = quote(f, safe="/")
- common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
- if classify(target.name) == "image":
- # prev/next image nav (wraps around; only when >1 image in the booth)
- names = booth_image_names(booth)
+
+ items = booth_items(booth)
+ item = find_item(items, f)
+ common = {
+ **base_ctx,
+ "name": name,
+ "name_url": quote(name, safe=""),
+ "file": f,
+ "file_url": quote(f, safe="/"),
+ # The facts this route never used to carry.
+ "caption": item.caption if item else None,
+ "section": item.section if item else None,
+ "blurred": item.blurred if item else False,
+ }
+
+ if item is not None and item.kind == "image":
+ # prev/next ring (wraps; only when there is more than one image)
+ names = image_chain(items)
prev_url = next_url = None
if f in names and len(names) > 1:
i = names.index(f)
@@ -875,19 +809,22 @@ def create_app(
return templates.TemplateResponse(
request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
)
+
# .md renders, .txt/.log show as text — viewable in-booth, no download
- dk = doc_kind(target.name)
- if dk:
- try:
- if target.stat().st_size <= DOC_MAX_BYTES:
- body, is_html = render_doc(target.read_text(encoding="utf-8", errors="replace"), dk)
- return templates.TemplateResponse(
- request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
- )
- except OSError:
- raise HTTPException(status_code=404, detail="no such file")
+ if item is not None:
+ body = render_doc_body(booth, item)
+ if body is not None:
+ rendered, is_html = body
+ return templates.TemplateResponse(
+ request,
+ "doc.html",
+ {**common, "kind": item.doc, "body": rendered, "is_html": is_html},
+ )
+
# nothing to render — hand back the raw file
- return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
+ return RedirectResponse(
+ url=f"/b/{quote(name, safe='')}/{quote(f, safe='/')}", status_code=307
+ )
@app.get("/b/{name}/{filepath:path}")
def booth_file(name: str, filepath: str, dl: int = 0):
diff --git a/booth/items.py b/booth/items.py
new file mode 100644
index 0000000..6c68509
--- /dev/null
+++ b/booth/items.py
@@ -0,0 +1,244 @@
+"""The item record — ONE resolver for what is in a booth.
+
+Before this module, three functions independently walked a booth and derived
+overlapping subsets of the same facts: `build_gallery` (kind, caption, blur,
+doc), `booth_view_file` (kind, doc, image ring) and `list_booths` (kind counts,
+cover thumb). The zoom route's subset was the smallest, and the fact it lacked
+was the caption — so an annotated image lost its annotation at exactly the size
+where the annotation is most readable.
+
+That was never a rendering bug. It was three readers of one truth. This module
+is the one truth; every surface reads its record and derives nothing itself.
+
+See docs/contracts/u1_item_record.contract.md.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Sequence
+from urllib.parse import quote
+
+try: # optional: markdown rendering degrades to raw text without it
+ import markdown as _markdown
+except ImportError: # pragma: no cover
+ _markdown = None
+
+from booth.asks import is_answer_file, is_ask_file
+
+# Browser-playable media buckets. Anything else renders as a download link.
+IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
+VIDEO_EXTS = {".webm", ".mp4", ".ogv", ".m4v", ".mov"}
+AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"}
+
+# Loose text docs that render as a readable page rather than a download.
+MARKDOWN_EXTS = {".md", ".markdown", ".mdown"}
+TEXT_EXTS = {".txt", ".text", ".log"}
+
+CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
+DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
+
+BLUR_FILE = ".blurred"
+
+
+def classify(name: str) -> str:
+ """image | video | audio | other, by extension."""
+ ext = Path(name).suffix.lower()
+ if ext in IMAGE_EXTS:
+ return "image"
+ if ext in VIDEO_EXTS:
+ return "video"
+ if ext in AUDIO_EXTS:
+ return "audio"
+ return "other"
+
+
+def doc_kind(name: str) -> str | None:
+ """'markdown' | 'text' | None — a booth file viewable as a readable page."""
+ ext = Path(name).suffix.lower()
+ if ext in MARKDOWN_EXTS:
+ return "markdown"
+ if ext in TEXT_EXTS:
+ return "text"
+ return None
+
+
+def render_doc(text: str, kind: str) -> tuple[str, bool]:
+ """(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists);
+ plain text — or markdown when the lib is unavailable — → raw text for .
+
+ Text is returned RAW on purpose: the template escapes it inside , and
+ pre-escaping here would double-encode under Jinja autoescape.
+ """
+ if kind == "markdown" and _markdown is not None:
+ html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
+ return html, True
+ return text, False
+
+
+@dataclass(frozen=True)
+class Item:
+ """One renderable file in a booth, with every fact any surface needs.
+
+ `rel` is the identity — the booth-relative POSIX path. Marks (U2) attach to
+ it, blur is keyed by it, and the zoom route resolves by it.
+ """
+
+ rel: str
+ url: str
+ kind: str
+ section: str | None
+ caption: str | None
+ blurred: bool
+ doc: str | None
+ size: int
+
+
+def read_blurred(booth: Path) -> set[str]:
+ """Blurred item paths for a booth. Missing file -> empty set."""
+ try:
+ text = (booth / BLUR_FILE).read_text()
+ except (OSError, UnicodeDecodeError):
+ return set()
+ return {ln.strip() for ln in text.splitlines() if ln.strip()}
+
+
+def _section_of(rel: str) -> str | None:
+ """The item's parent directory relative to the booth; None at the root.
+
+ Derived, never stored. This is the whole input to the navigation fix (U7):
+ the structure a poster already created on disk, which `rglob` has been
+ flattening into one wall at render time.
+ """
+ parent = Path(rel).parent
+ return None if str(parent) == "." else parent.as_posix()
+
+
+def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]:
+ """(caption-by-rel, rels consumed as sidecars).
+
+ Two forms, in this precedence, preserved from the original gallery:
+ 1. `.txt` — `a.png.txt` captions `a.png`
+ 2. `.txt` beside a same-stem MEDIA sibling — `a.txt` captions
+ `a.png`, but NOT `a.bin` (the `classify != "other"` guard, so a stray
+ `data.txt` next to `data.bin` stays an item of its own)
+
+ The sibling scan runs in sorted order rather than filesystem order: when two
+ media files share a stem in one directory (`a.png` and `a.webm`), the
+ original picked whichever `rglob` happened to yield first. Same rule, now
+ deterministic.
+ """
+ caption: dict[str, str] = {}
+ sidecars: set[str] = set()
+
+ for rel in sorted(by_rel):
+ if not rel.lower().endswith(".txt"):
+ continue
+ p = by_rel[rel]
+ target = None
+
+ base_full = rel[:-4] # "a.png.txt" -> "a.png"
+ if base_full in by_rel:
+ target = base_full
+ else:
+ parent = str(Path(rel).parent)
+ stem = Path(rel).stem
+ for q_rel in sorted(by_rel):
+ if q_rel == rel:
+ continue
+ q = by_rel[q_rel]
+ if (
+ str(Path(q_rel).parent) == parent
+ and Path(q_rel).stem == stem
+ and classify(q.name) != "other"
+ ):
+ target = q_rel
+ break
+
+ if target is not None:
+ try:
+ caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
+ except OSError:
+ pass
+ sidecars.add(rel)
+
+ return caption, sidecars
+
+
+def booth_items(booth: Path) -> list[Item]:
+ """Every renderable file in a booth, sorted by relative path.
+
+ Excluded: dotfiles, `*.ask.json` / `*.answer.json` (they render as the asks
+ panel, not as tiles), and any file consumed as another item's caption.
+
+ Doc BODIES are deliberately not rendered here. The index calls this once per
+ booth to count items and pick a cover; rendering every doc in every booth on
+ every page load would be the cost of that convenience. `render_doc_body` is
+ the separate step, for the one consumer that needs it.
+ """
+ by_rel: dict[str, Path] = {}
+ for p in booth.rglob("*"):
+ if not p.is_file() or p.name.startswith("."):
+ continue
+ if is_ask_file(p.name) or is_answer_file(p.name):
+ continue
+ by_rel[p.relative_to(booth).as_posix()] = p
+
+ caption, sidecars = _resolve_captions(by_rel)
+ blurred = read_blurred(booth) # ONE read per call, not one per item
+
+ items: list[Item] = []
+ for rel in sorted(by_rel):
+ if rel in sidecars:
+ continue
+ p = by_rel[rel]
+ try:
+ size = p.stat().st_size
+ except OSError:
+ size = 0
+ items.append(
+ Item(
+ rel=rel,
+ url=quote(rel, safe="/"),
+ kind=classify(p.name),
+ section=_section_of(rel),
+ caption=caption.get(rel),
+ blurred=rel in blurred,
+ doc=doc_kind(p.name),
+ size=size,
+ )
+ )
+ return items
+
+
+def image_chain(items: Sequence[Item]) -> list[str]:
+ """The rels of the image items, in order — the zoom view's prev/next ring.
+
+ Replaces `booth_image_names`, which walked the tree a second time to derive
+ what the item list already knows.
+ """
+ return [it.rel for it in items if it.kind == "image"]
+
+
+def find_item(items: Sequence[Item], rel: str) -> Item | None:
+ """The record for one rel, or None — the zoom/doc route's entry point."""
+ for it in items:
+ if it.rel == rel:
+ return it
+ return None
+
+
+def render_doc_body(booth: Path, item: Item) -> tuple[str, bool] | None:
+ """(body, is_html) for a doc item under DOC_MAX_BYTES, else None.
+
+ None means "do not inline this": either it is not a doc, or it is a log big
+ enough that inlining it into every page render is the wrong trade.
+ """
+ if item.doc is None or item.size > DOC_MAX_BYTES:
+ return None
+ try:
+ text = (booth / item.rel).read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return None
+ return render_doc(text, item.doc)
diff --git a/booth/templates/doc.html b/booth/templates/doc.html
index 265a0c5..66bed5a 100644
--- a/booth/templates/doc.html
+++ b/booth/templates/doc.html
@@ -8,6 +8,9 @@
⬇
+ {# Same record, same reason as the image viewer: the sidecar that says what
+ this doc IS travels with it to full-page view. #}
+ {% if caption %}{{ caption }}{% endif %}
{% if is_html %}
{{ body|safe }}
{% else %}
@@ -19,6 +22,10 @@
gallery view). Only the full-page layout wrapper is page-specific. */
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
.docview .textview{overflow-x:auto}
+ .doccap{margin:.9rem 0 1.2rem;padding:.6rem .85rem;font-size:.85rem;line-height:1.5;
+ color:var(--fg-1);background:var(--rk-surface,rgba(255,255,255,.04));
+ border-left:2px solid var(--aus-bright-cyan,#42dcd1);border-radius:0 6px 6px 0;
+ white-space:pre-wrap}