feat: add vault download button and markdown viewer PRD to /files page
All checks were successful
Deploy / deploy (push) Successful in 12s

Adds GET /files/download endpoint that streams all vault files (excl.
.git) as a deflate-compressed vault.zip. Adds Download All button to
the /files page header. Also adds FILES_VIEWER_PRD.md planning doc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 19:35:25 +01:00
parent 9e143a8156
commit 49ab2a1599
2 changed files with 185 additions and 1 deletions

162
FILES_VIEWER_PRD.md Normal file
View File

@@ -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=<relative-path>` that serves a rendered HTML view of the file
- Frontmatter stripped from rendered output; optionally shown as a collapsible `<details>` 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=<url-encoded-relative-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
<a href="/files/view?path=<encoded-relative-path>">{item.name}</a>
```
Non-`.md` files remain as plain `<code>` 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 `<details>` 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 `<code>` labels.
**Viewer page layout:**
```
┌──────────────────────────────────────────────────────┐
│ ← Back to Files Collection/Document.md │
├──────────────────────────────────────────────────────┤
│ [▶ Sync metadata] (collapsed <details>) │
├──────────────────────────────────────────────────────┤
│ │
│ # 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 `<pre>` block |

View File

@@ -12,11 +12,13 @@ Module-level VAULT_DIR and SETTINGS_PATH can be overridden by tests.
import asyncio import asyncio
import base64 import base64
import difflib import difflib
import io
import json import json
import os import os
import re import re
import subprocess import subprocess
import uuid import uuid
import zipfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -797,6 +799,23 @@ async def sync_history(request: Request):
# File browser # 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) @app.get("/files", response_class=HTMLResponse)
async def file_browser(): async def file_browser():
tree_html = _get_vault_tree_html() tree_html = _get_vault_tree_html()
@@ -811,7 +830,10 @@ async def file_browser():
<div class="card"> <div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2 style="margin:0">Vault Files ({file_count})</h2> <h2 style="margin:0">Vault Files ({file_count})</h2>
<small style="color:#999"><code>{VAULT_DIR}</code></small> <div style="display:flex;align-items:center;gap:12px">
<a href="/files/download" class="btn btn-secondary" style="font-size:.8rem;padding:6px 14px">&#x2B73; Download All</a>
<small style="color:#999"><code>{VAULT_DIR}</code></small>
</div>
</div> </div>
<div style="font-size:.9rem;line-height:1.6">{tree_html}</div> <div style="font-size:.9rem;line-height:1.6">{tree_html}</div>
</div>""" </div>"""