feat: add vault download button and markdown viewer PRD to /files page
All checks were successful
Deploy / deploy (push) Successful in 12s
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:
162
FILES_VIEWER_PRD.md
Normal file
162
FILES_VIEWER_PRD.md
Normal 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 |
|
||||||
22
webui.py
22
webui.py
@@ -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,8 +830,11 @@ 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>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<a href="/files/download" class="btn btn-secondary" style="font-size:.8rem;padding:6px 14px">⭳ Download All</a>
|
||||||
<small style="color:#999"><code>{VAULT_DIR}</code></small>
|
<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>"""
|
||||||
return HTMLResponse(_page("Files", body))
|
return HTMLResponse(_page("Files", body))
|
||||||
|
|||||||
Reference in New Issue
Block a user