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}