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:
+47
-15
@@ -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)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"view.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"name": name,
|
||||
"name_url": quote(name, safe=""),
|
||||
"file": f,
|
||||
"file_url": file_url,
|
||||
},
|
||||
)
|
||||
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, "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):
|
||||
|
||||
Reference in New Issue
Block a user