feat(booth): view .md (rendered) and .txt/.log in-booth without downloading
Loose .md/.txt/.log files rendered as forced-download links in the gallery
and downloaded (or showed raw) when opened. Now they open in a readable
in-booth page via the existing /b/<name>/view route:
- .md -> rendered HTML (Python-Markdown: fenced code, tables, sane lists),
styled in an Australis .markdown-body with the viewer chrome;
- .txt/.log -> preformatted <pre> text view.
The gallery links docs to the viewer (📄) instead of a download; the view
page keeps a ⬇ (?dl=1) for saving. Files over 2 MB hand back raw. New
markdown dep (optional-import: degrades .md to text view if absent).
booth_image_view -> booth_view_file (now handles image + doc + raw-fallback).
9 new tests, suite 44 passing; deployed + verified live on nh3-dev :8090.
This commit is contained in:
+45
-13
@@ -38,6 +38,11 @@ from fastapi.responses import (
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
try:
|
||||
import markdown as _markdown
|
||||
except ImportError: # optional dep — .md then degrades to a plain-text view
|
||||
_markdown = None
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
# Browser-playable media buckets. Anything else renders as a download link.
|
||||
@@ -47,6 +52,30 @@ AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"}
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 <pre>."""
|
||||
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 classify(name: str) -> str:
|
||||
"""image | video | audio | other, by extension."""
|
||||
@@ -203,6 +232,7 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
{
|
||||
"name": rel,
|
||||
"kind": classify(p.name),
|
||||
"doc": doc_kind(p.name),
|
||||
"url": quote(rel, safe="/"),
|
||||
"caption": caption.get(rel),
|
||||
}
|
||||
@@ -484,7 +514,7 @@ def create_app(
|
||||
)
|
||||
|
||||
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||||
def booth_image_view(request: Request, name: str, f: str):
|
||||
def booth_view_file(request: Request, name: str, f: str):
|
||||
booth = resolve_booth(name)
|
||||
try:
|
||||
target = (booth / f).resolve()
|
||||
@@ -493,20 +523,22 @@ def create_app(
|
||||
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="/")
|
||||
if classify(target.name) != "image":
|
||||
# nothing to zoom on a non-image — hand back the raw file
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
|
||||
common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
|
||||
if classify(target.name) == "image":
|
||||
return templates.TemplateResponse(request, "view.html", common)
|
||||
# .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,
|
||||
"view.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"name": name,
|
||||
"name_url": quote(name, safe=""),
|
||||
"file": f,
|
||||
"file_url": file_url,
|
||||
},
|
||||
request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
|
||||
)
|
||||
except OSError:
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
# nothing to render — hand back the raw file
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
|
||||
|
||||
@app.get("/b/{name}/{filepath:path}")
|
||||
def booth_file(name: str, filepath: str, dl: int = 0):
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<video controls preload="metadata" src="{{ it.url }}"></video>
|
||||
{% elif it.kind == 'audio' %}
|
||||
<audio controls preload="metadata" src="{{ it.url }}"></audio>
|
||||
{% elif it.doc %}
|
||||
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
|
||||
{% else %}
|
||||
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||||
{% block content %}
|
||||
<div class="docview">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<span class="vspacer"></span>
|
||||
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a>
|
||||
</div>
|
||||
{% if is_html %}
|
||||
<article class="markdown-body">{{ body|safe }}</article>
|
||||
{% else %}
|
||||
<pre class="textview">{{ body }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
<style>
|
||||
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
|
||||
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
|
||||
font-size:.86rem;line-height:1.5;color:var(--fg-1);background:var(--rk-well);
|
||||
border:1px solid var(--rk-line,#252a35);border-radius:10px;padding:1rem 1.15rem;overflow-x:auto}
|
||||
.markdown-body{color:var(--fg-1);line-height:1.62;font-size:.98rem;overflow-wrap:break-word}
|
||||
.markdown-body h1,.markdown-body h2,.markdown-body h3{line-height:1.25;margin:1.6em 0 .5em}
|
||||
.markdown-body h1{font-size:1.7em}.markdown-body h2{font-size:1.35em}.markdown-body h3{font-size:1.12em}
|
||||
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--rk-line,#252a35);padding-bottom:.3em}
|
||||
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
|
||||
.markdown-body a{color:var(--aus-bright-cyan,#42dcd1)}
|
||||
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--rk-well);
|
||||
padding:.12em .38em;border-radius:5px}
|
||||
.markdown-body pre{background:var(--rk-well);border:1px solid var(--rk-line,#252a35);
|
||||
border-radius:10px;padding:.9rem 1.05rem;overflow-x:auto}
|
||||
.markdown-body pre code{background:none;padding:0}
|
||||
.markdown-body blockquote{border-left:3px solid var(--aus-bright-cyan,#42dcd1);
|
||||
padding-left:1em;color:var(--fg-2);margin-left:0}
|
||||
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
|
||||
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
|
||||
.markdown-body img{max-width:100%}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.34",
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"markdown>=3.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -10,9 +10,11 @@ from booth.app import (
|
||||
build_gallery,
|
||||
classify,
|
||||
create_app,
|
||||
doc_kind,
|
||||
generate_pickup_id,
|
||||
human_dur,
|
||||
is_expired,
|
||||
render_doc,
|
||||
safe_upload_name,
|
||||
sweep_once,
|
||||
wrap_verbatim_html,
|
||||
@@ -353,12 +355,12 @@ def test_image_view_traversal_404(client):
|
||||
assert c.get("/b/run1/view", params={"f": "../../etc/passwd"}).status_code == 404
|
||||
|
||||
|
||||
def test_image_view_nonimage_redirects_to_raw(client):
|
||||
def test_view_nonviewable_redirects_to_raw(client):
|
||||
c, data = client
|
||||
_touch(data / "run1" / "notes.txt")
|
||||
r = c.get("/b/run1/view", params={"f": "notes.txt"}, follow_redirects=False)
|
||||
_touch(data / "run1" / "data.bin") # not image/md/txt -> nothing to render, hand back raw
|
||||
r = c.get("/b/run1/view", params={"f": "data.bin"}, follow_redirects=False)
|
||||
assert r.status_code == 307
|
||||
assert r.headers["location"] == "/b/run1/notes.txt"
|
||||
assert r.headers["location"] == "/b/run1/data.bin"
|
||||
|
||||
|
||||
# ---- verbatim-index.html wrapper --------------------------------------------
|
||||
@@ -435,3 +437,59 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||
r = c.get("/b/brief/index.html")
|
||||
assert r.status_code == 200
|
||||
assert "booth-nav-home" not in r.text
|
||||
|
||||
|
||||
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
||||
|
||||
|
||||
def test_doc_kind():
|
||||
assert doc_kind("notes.md") == "markdown"
|
||||
assert doc_kind("a.markdown") == "markdown"
|
||||
assert doc_kind("log.txt") == "text"
|
||||
assert doc_kind("run.log") == "text"
|
||||
assert doc_kind("shot.png") is None
|
||||
assert doc_kind("data.bin") is None
|
||||
|
||||
|
||||
def test_render_doc_markdown():
|
||||
html, is_html = render_doc("# Title\n\n- a\n- b\n", "markdown")
|
||||
assert is_html is True
|
||||
assert "<h1>" in html and "Title" in html
|
||||
assert "<li>" in html
|
||||
|
||||
|
||||
def test_render_doc_text_is_verbatim():
|
||||
body, is_html = render_doc("plain\ntext", "text")
|
||||
assert is_html is False and body == "plain\ntext"
|
||||
|
||||
|
||||
def test_view_markdown_renders(client):
|
||||
c, data = client
|
||||
d = data / "run1"; d.mkdir()
|
||||
(d / "notes.md").write_text("# Heading\n\nsome **bold** text\n")
|
||||
r = c.get("/b/run1/view", params={"f": "notes.md"})
|
||||
assert r.status_code == 200
|
||||
assert "<h1>" in r.text and "Heading" in r.text # rendered, not raw markdown
|
||||
assert "<strong>bold</strong>" in r.text
|
||||
assert "attachment" not in r.headers.get("content-disposition", "") # viewed, not downloaded
|
||||
|
||||
|
||||
def test_view_text_shows_preformatted(client):
|
||||
c, data = client
|
||||
d = data / "run1"; d.mkdir()
|
||||
(d / "out.txt").write_text("line one\nline two")
|
||||
r = c.get("/b/run1/view", params={"f": "out.txt"})
|
||||
assert r.status_code == 200
|
||||
assert "<pre" in r.text and "line one" in r.text
|
||||
assert "attachment" not in r.headers.get("content-disposition", "")
|
||||
|
||||
|
||||
def test_gallery_links_docs_to_view(client):
|
||||
c, data = client
|
||||
d = data / "run1"; d.mkdir()
|
||||
(d / "readme.md").write_text("# hi")
|
||||
(d / "notes.txt").write_text("hello") # loose txt (no media partner) -> own item
|
||||
page = c.get("/b/run1/")
|
||||
assert "view?f=readme.md" in page.text # md -> viewer
|
||||
assert "view?f=notes.txt" in page.text # txt -> viewer
|
||||
assert 'href="readme.md" download' not in page.text # not a forced download
|
||||
|
||||
Reference in New Issue
Block a user