diff --git a/FILES_VIEWER_PRD.md b/FILES_VIEWER_PRD.md new file mode 100644 index 0000000..4756848 --- /dev/null +++ b/FILES_VIEWER_PRD.md @@ -0,0 +1,162 @@ +# PRD: Markdown Viewer for /files Page + +**Status:** Draft +**Date:** 2026-03-13 +**Scope:** outline-sync webui — `/files` page enhancement + +--- + +## Problem + +The `/files` page currently displays a static file tree showing document names and file sizes. Files are not interactive — there is no way to read document content within the web UI. Users who want to verify a document's content must access the vault directory directly (container shell, WebDAV mount, or Obsidian). + +This breaks the self-contained nature of the web UI and is a missing piece in the read workflow: pull → verify content → push. + +--- + +## Goal + +Make every `.md` file in the vault tree clickable, opening a clean rendered view of the document without leaving the web UI. + +--- + +## Library Assessment + +The [awesome-markdown-editors](https://github.com/mundimark/awesome-markdown-editors) list is focused primarily on **editors** (WYSIWYG, Monaco-based, cloud tools), not standalone viewers/renderers. The relevant rendering engines mentioned are `markdown-it`, `marked.js`, `highlight.js`, `KaTeX`, and `Mermaid`. + +For this project's architecture — a pure FastAPI app with all HTML/CSS/JS inline in `webui.py`, no npm, no build system, no CDN currently in use — the viable options are: + +| Approach | Library | Pro | Con | +|----------|---------|-----|-----| +| Server-side (Python) | `mistune` | No CDN, consistent with existing diff rendering, works offline/air-gapped | New pip dependency | +| Server-side (Python) | `markdown2` | Same as above, supports fenced code + tables via extras | New pip dependency | +| Client-side (CDN) | `marked.js` | Zero Python deps, ~50 KB | CDN call from container; breaks if offline | +| Client-side (CDN) | `markdown-it` | Plugin ecosystem | Same CDN concern + more complex | + +**Recommendation: `mistune` (server-side)** +Reason: The existing diff renderer (`difflib.HtmlDiff`) is already server-side. `mistune` is a lightweight pure-Python CommonMark-compatible parser (~25 KB, no transitive deps). It keeps the architecture consistent, avoids CDN latency from inside Docker, and doesn't require any frontend changes to `_SCRIPT`. + +`markdown2` is an equally valid alternative with a nearly identical API. + +--- + +## User Stories + +1. **As a user**, I can click any `.md` file in the vault tree to open a rendered view of its contents. +2. **As a user**, I can see the document's markdown rendered as formatted HTML (headings, lists, code blocks, bold/italic, tables). +3. **As a user**, the YAML frontmatter block (`---` ... `---`) is either hidden or rendered as a collapsible metadata section, not shown as raw YAML in the document body. +4. **As a user**, I can navigate back to the file tree with a single click (back link or breadcrumb). +5. **As a user**, non-markdown files (e.g. `.json`, `.txt`) show a plain text fallback, not an error. +6. **As a user**, attempting to view a file outside the vault directory results in a 403, not a path traversal. + +--- + +## Scope + +### In scope +- New route `GET /files/view?path=` that serves a rendered HTML view of the file +- Frontmatter stripped from rendered output; optionally shown as a collapsible `
` block with the sync metadata fields +- Clickable file links in `_build_tree_html()` for `.md` files (and other text files) +- Back-navigation link to `/files` +- Path traversal protection: resolve the path and assert it is within `VAULT_DIR` +- `mistune` added to `Dockerfile` pip install line + +### Out of scope +- Editing files within the viewer (separate feature) +- Syntax highlighting for code blocks (can be added later with `pygments`) +- Full-text search across vault files +- Non-text binary files (images, PDFs) +- Live refresh / watch mode + +--- + +## Technical Design + +### New route + +```python +GET /files/view?path= +``` + +- Decode and resolve path relative to `VAULT_DIR` +- Assert resolved path is inside `VAULT_DIR` (403 if not) +- Assert file exists and is a regular file (404 if not) +- Read content as UTF-8 +- Strip frontmatter using existing `parse_frontmatter()` from `outline_sync.py` +- Render body with `mistune.create_markdown()` +- Wrap in `_page()` with a back-link header card and the rendered content card + +### File tree changes + +In `_build_tree_html()`, wrap `.md` file names in anchor tags: +```html +{item.name} +``` + +Non-`.md` files remain as plain `` labels (or optionally link to a raw text view). + +### Frontmatter handling + +Use the already-existing `parse_frontmatter()` helper to split metadata from body before rendering. Optionally render frontmatter fields in a collapsed `
` block styled consistently with the existing card/badge UI. + +### Path traversal protection + +```python +resolved = (VAULT_DIR / rel_path).resolve() +if not str(resolved).startswith(str(VAULT_DIR.resolve())): + raise HTTPException(status_code=403) +``` + +### Dependency change + +`Dockerfile` pip install line gains `mistune`: +``` +pip install --no-cache-dir requests fastapi "uvicorn[standard]" pydantic mistune +``` + +--- + +## UI Design + +**File tree** — `.md` files become blue underlined links. Non-markdown files stay as grey `` labels. + +**Viewer page layout:** +``` +┌──────────────────────────────────────────────────────┐ +│ ← Back to Files Collection/Document.md │ +├──────────────────────────────────────────────────────┤ +│ [▶ Sync metadata] (collapsed
) │ +├──────────────────────────────────────────────────────┤ +│ │ +│ # Document Title │ +│ │ +│ Rendered markdown body... │ +│ │ +└──────────────────────────────────────────────────────┘ +``` + +Styling reuses existing `.card` and `_BASE_CSS` variables — no new CSS classes needed beyond a `.md-body` prose wrapper (max-width, line-height, heading sizes). + +--- + +## Acceptance Criteria + +- [ ] Clicking a `.md` file in the vault tree navigates to `/files/view?path=...` +- [ ] Rendered page shows formatted headings, paragraphs, lists, bold/italic, code blocks, and tables +- [ ] YAML frontmatter is not visible in the rendered body +- [ ] Requesting `/files/view?path=../../etc/passwd` returns 403 +- [ ] Requesting a non-existent path returns 404 +- [ ] Back link returns to `/files` and preserves the tree state +- [ ] `mistune` is installed in the Docker image (Dockerfile updated) +- [ ] No new test files required for MVP, but the new route is covered by a basic integration test asserting 200, 403, 404 + +--- + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Path traversal to read secrets outside vault | Strict `resolve()` + prefix check; test explicitly | +| `mistune` API changes (v2 vs v3) | Pin version in Dockerfile; use `mistune.create_markdown()` stable API | +| Very large files causing slow response | Enforce a max file size read limit (e.g. 2 MB) with a warning | +| Malformed/binary `.md` files | Wrap read in try/except, fall back to plain `
` block |
diff --git a/webui.py b/webui.py
index c1c9265..b127c13 100644
--- a/webui.py
+++ b/webui.py
@@ -12,11 +12,13 @@ Module-level VAULT_DIR and SETTINGS_PATH can be overridden by tests.
 import asyncio
 import base64
 import difflib
+import io
 import json
 import os
 import re
 import subprocess
 import uuid
+import zipfile
 from pathlib import Path
 from typing import Optional
 
@@ -797,6 +799,23 @@ async def sync_history(request: Request):
 # File browser
 # ---------------------------------------------------------------------------
 
+@app.get("/files/download")
+async def download_vault():
+    if not VAULT_DIR.exists():
+        raise HTTPException(status_code=404, detail="Vault directory not found")
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        for file_path in sorted(VAULT_DIR.rglob("*")):
+            if file_path.is_file() and ".git" not in file_path.parts:
+                zf.write(file_path, file_path.relative_to(VAULT_DIR))
+    buf.seek(0)
+    return StreamingResponse(
+        buf,
+        media_type="application/zip",
+        headers={"Content-Disposition": 'attachment; filename="vault.zip"'},
+    )
+
+
 @app.get("/files", response_class=HTMLResponse)
 async def file_browser():
     tree_html = _get_vault_tree_html()
@@ -811,7 +830,10 @@ async def file_browser():
 

Vault Files ({file_count})

- {VAULT_DIR} +
+ ⭳ Download All + {VAULT_DIR} +
{tree_html}
"""