#!/usr/bin/env python3 """ Outline Sync Web UI — FastAPI + HTMX Phases B–G of WEBUI_PRD.md Run inside outline-sync-ui Docker container: uvicorn webui:app --host 0.0.0.0 --port 8080 Module-level VAULT_DIR and SETTINGS_PATH can be overridden by tests. """ import asyncio import base64 import difflib import json import os import re import subprocess import uuid from pathlib import Path from typing import Optional from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from pydantic import BaseModel, field_validator # --------------------------------------------------------------------------- # Module-level config — overridden by tests: webui.VAULT_DIR = tmp_path # --------------------------------------------------------------------------- VAULT_DIR: Path = Path(os.environ.get("VAULT_DIR", "/vault")) SETTINGS_PATH: Path = Path(os.environ.get("SETTINGS_PATH", "/work/settings.json")) # --------------------------------------------------------------------------- # App + job state # --------------------------------------------------------------------------- app = FastAPI(title="Outline Sync UI", docs_url=None, redoc_url=None) _jobs: dict[str, dict] = {} _active_job: Optional[str] = None # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- def _git(*args: str) -> subprocess.CompletedProcess: """Run a git command against VAULT_DIR (no check — callers inspect returncode).""" return subprocess.run( ["git", "-C", str(VAULT_DIR), *args], capture_output=True, text=True, ) # --------------------------------------------------------------------------- # Settings # --------------------------------------------------------------------------- def _load_settings() -> dict: try: return json.loads(SETTINGS_PATH.read_text()) except Exception: return {} # --------------------------------------------------------------------------- # Vault state # --------------------------------------------------------------------------- def _get_conflict_files() -> list[str]: """Return sorted list of paths with unresolved merge conflicts.""" r = _git("ls-files", "-u") seen: set[str] = set() for line in r.stdout.splitlines(): parts = line.split("\t") if len(parts) >= 2: seen.add(parts[1]) return sorted(seen) def _get_pending_count() -> int: r = _git("diff", "outline..main", "--name-only") return len([l for l in r.stdout.splitlines() if l.strip()]) def _get_pending_changes() -> list[dict]: r = _git("diff", "outline..main", "--name-status", "-M90") allow_deletions = _load_settings().get("sync", {}).get("allow_deletions", False) changes: list[dict] = [] for line in r.stdout.splitlines(): if not line.strip(): continue parts = line.split("\t") code = parts[0] if code == "M" and len(parts) >= 2: changes.append({"path": parts[1], "status": "modified", "action": "update"}) elif code == "A" and len(parts) >= 2: changes.append({"path": parts[1], "status": "added", "action": "create"}) elif code == "D" and len(parts) >= 2: action = "delete" if allow_deletions else "skip" changes.append({ "path": parts[1], "status": "deleted", "action": action, "reason": "" if allow_deletions else "deletions disabled in settings", }) elif code.startswith("R") and len(parts) >= 3: changes.append({ "path": parts[2], "status": "renamed", "action": "update", "from": parts[1], "to": parts[2], "from_path": parts[1], "to_path": parts[2], }) return changes def _parse_sync_log() -> list[dict]: """Parse _sync_log.md table rows into dicts, returned newest-first.""" log_path = VAULT_DIR / "_sync_log.md" if not log_path.exists(): return [] entries: list[dict] = [] past_header = False for line in log_path.read_text().splitlines(): stripped = line.strip() if not stripped.startswith("|"): continue # Separator row if re.match(r"^\|[-| :]+\|$", stripped): past_header = True continue if not past_header: continue # skip header row cells = [c.strip() for c in stripped.strip("|").split("|")] if len(cells) >= 4: entries.append({ "timestamp": cells[0], "direction": cells[1], "files": cells[2], "status": cells[3], }) entries.reverse() # newest first return entries def _get_vault_status() -> dict: conflict_files = _get_conflict_files() pending_count = _get_pending_count() if conflict_files: vault_status = "conflict" elif pending_count > 0: vault_status = "dirty" else: vault_status = "clean" last_pull: Optional[dict] = None last_push: Optional[dict] = None for e in _parse_sync_log(): if last_pull is None and e.get("direction") == "pull": last_pull = e if last_push is None and e.get("direction") == "push": last_push = e return { "vault_status": vault_status, "pending_count": pending_count, "conflicts": len(conflict_files), "last_pull": last_pull, "last_push": last_push, } # --------------------------------------------------------------------------- # Vault file tree # --------------------------------------------------------------------------- def _build_tree_html(path: Path, depth: int = 0) -> str: """Recursively build a nested HTML tree for a directory, excluding .git.""" items = sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())) indent = 16 if depth > 0 else 0 html = f'" return html def _get_vault_tree_html() -> str: if not VAULT_DIR.exists(): return "Vault directory not found." try: return _build_tree_html(VAULT_DIR) except Exception as exc: return f"Error reading vault: {exc}" # --------------------------------------------------------------------------- # Async subprocess + job runner # --------------------------------------------------------------------------- async def spawn_sync_subprocess(command: str) -> asyncio.subprocess.Process: """ Run outline_sync.py directly — we are already inside the container. Patched in tests. """ return await asyncio.create_subprocess_exec( "python3", "/work/outline_sync.py", command, "--vault", str(VAULT_DIR), "--settings", str(SETTINGS_PATH), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) async def run_sync_job(job_id: str, command: str) -> None: """ Execute a sync job, streaming output into _jobs[job_id]["output"]. Patched in tests via: patch("webui.run_sync_job", new_callable=AsyncMock). """ global _active_job try: proc = await spawn_sync_subprocess(command) summary_line = "" async for raw in proc.stdout: text = raw.decode(errors="replace").rstrip() _jobs[job_id]["output"].append({"type": "log", "message": text}) if text.startswith("Done."): summary_line = text await proc.wait() success = proc.returncode == 0 _jobs[job_id]["output"].append({ "type": "done", "success": success, "message": summary_line or ("Sync completed." if success else "Sync failed."), }) _jobs[job_id]["status"] = "done" if success else "error" except Exception as exc: _jobs[job_id]["output"].append({"type": "done", "success": False, "message": str(exc)}) _jobs[job_id]["status"] = "error" def _new_job(command: str) -> str: """ Register a new job. Status is 'pending' until the SSE stream connects and starts it. This avoids asyncio background tasks that cause test hangs. """ global _active_job job_id = str(uuid.uuid4()) _jobs[job_id] = {"status": "pending", "output": [], "command": command} _active_job = job_id return job_id # --------------------------------------------------------------------------- # Diff renderer # --------------------------------------------------------------------------- def _render_diff_html(outline_text: str, main_text: str, filename: str) -> str: table = difflib.HtmlDiff(wrapcolumn=80).make_table( outline_text.splitlines(), main_text.splitlines(), fromdesc="Outline's version", todesc="Your version (Obsidian)", context=True, numlines=3, ) return f"""

Diff: {filename}

{table}
""" # --------------------------------------------------------------------------- # HTML helpers # --------------------------------------------------------------------------- _BADGE = { "clean": 'Clean', "dirty": 'Pending Changes', "conflict": 'Conflicts!', } _BASE_CSS = """ *{box-sizing:border-box} body{font-family:system-ui,sans-serif;margin:0;background:#f5f5f5;color:#222} header{background:#1a1a2e;color:#eee;padding:12px 24px;display:flex;justify-content:space-between;align-items:center} header h1{margin:0;font-size:1.2rem;letter-spacing:.05em} header nav a{color:#aac;text-decoration:none;margin-left:18px;font-size:.9rem} header nav a:hover{color:#fff} main{max-width:880px;margin:32px auto;padding:0 16px} .card{background:#fff;border-radius:8px;padding:20px 24px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,.08)} h2{margin:0 0 16px;font-size:1.1rem} .badge{display:inline-block;padding:3px 10px;border-radius:12px;font-size:.8rem;font-weight:600} .badge.clean{background:#d4edda;color:#155724} .badge.dirty{background:#fff3cd;color:#856404} .badge.conflict{background:#f8d7da;color:#721c24} .grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px} .stat{background:#f8f9fa;border-radius:6px;padding:12px} .stat label{font-size:.75rem;color:#666;display:block;margin-bottom:4px} .btn{display:inline-block;padding:10px 22px;border-radius:6px;border:none;cursor:pointer;font-size:.9rem;font-weight:600;text-decoration:none;transition:opacity .15s} .btn:hover{opacity:.85} .btn-primary{background:#0066cc;color:#fff} .btn-success{background:#198754;color:#fff} .btn-danger{background:#dc3545;color:#fff} .btn-secondary{background:#6c757d;color:#fff} .btn:disabled,.btn[disabled]{opacity:.5;cursor:not-allowed;pointer-events:none} .row{display:flex;gap:12px;flex-wrap:wrap;align-items:center} .alert{padding:12px 16px;border-radius:6px;margin-bottom:12px} .alert-warn{background:#fff3cd;border:1px solid #ffc107;color:#664d03} #output{background:#1a1a2e;color:#d0d0e0;border-radius:8px;padding:16px;font-family:monospace;font-size:.85rem;min-height:80px;max-height:600px;overflow-y:auto;margin-top:16px;display:none;resize:vertical} #output .ln{padding:1px 0} #output .ln.ok{color:#6ee7b7} #output .ln.err{color:#fca5a5} #output .ln.done{color:#93c5fd;font-weight:600;border-top:1px solid #333;margin-top:8px;padding-top:8px} table{width:100%;border-collapse:collapse} th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #eee;font-size:.9rem} th{background:#f8f9fa;font-weight:600} tr:last-child td{border-bottom:none} .tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.75rem;font-weight:600} .tag-modified{background:#fff3cd;color:#856404} .tag-added{background:#d4edda;color:#155724} .tag-deleted{background:#f8d7da;color:#721c24} .tag-renamed{background:#cce5ff;color:#004085} .tag-skip{background:#e2e3e5;color:#383d41} .conflict-card{border:1px solid #ffc107;border-radius:6px;padding:14px;margin-bottom:12px} .conflict-card h3{margin:0 0 10px;font-size:.95rem;font-family:monospace} .diff-container{margin-top:10px} """ _SCRIPT = r""" async function refreshStats() { try { const r = await fetch('/status'); if (!r.ok) return; const s = await r.json(); const set = (id, val) => { const el = document.getElementById(id); if (el) el.innerHTML = val; }; set('stat-pull', s.last_pull ? s.last_pull : '—'); set('stat-push', s.last_push ? s.last_push : '—'); set('stat-pending', s.pending_count ?? '?'); set('stat-status', s.vault_status ?? '?'); } catch(e) {} } async function doSync(endpoint, label) { const btn = event.currentTarget; btn.disabled = true; const r = await fetch(endpoint, {method:'POST'}); if (r.status === 409) { const d = await r.json(); alert(d.detail || 'A job is already running or conflicts exist.'); btn.disabled = false; return; } if (!r.ok) { alert('Error ' + r.status); btn.disabled = false; return; } const d = await r.json(); const panel = document.getElementById('output'); panel.style.display = 'block'; panel.innerHTML = '
' + label + '…
'; const src = new EventSource('/stream/' + d.job_id); src.onmessage = e => { const ev = JSON.parse(e.data); if (ev.type === 'done') { const div = document.createElement('div'); div.className = 'ln done'; div.textContent = ev.message || 'Done.'; panel.appendChild(div); panel.scrollTop = panel.scrollHeight; src.close(); btn.disabled = false; refreshStats(); return; } const div = document.createElement('div'); div.className = 'ln' + (ev.type==='error'?' err': ev.message&&ev.message.startsWith('ok:')?' ok':''); div.textContent = ev.message || JSON.stringify(ev); panel.appendChild(div); panel.scrollTop = panel.scrollHeight; }; } function toggleDiff(path, encPath) { const id = 'diff_' + path.replace(/[^a-zA-Z0-9]/g,'_'); const el = document.getElementById(id); if (!el) return; if (el.dataset.loaded) { el.style.display = el.style.display==='none'?'block':'none'; return; } el.style.display = 'block'; el.innerHTML = 'Loading…'; fetch('/diff/' + encPath).then(r=>r.text()).then(h=>{el.innerHTML=h;el.dataset.loaded='1'}); } async function resolve(path, accept) { const r = await fetch('/resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:path,accept:accept})}); if (!r.ok) { const d=await r.json().catch(()=>({})); alert(d.detail||'Error'); return; } const id = 'cc_' + path.replace(/[^a-zA-Z0-9]/g,'_'); document.getElementById(id)?.remove(); if (!document.querySelector('.conflict-card')) { document.getElementById('cc-list').style.display='none'; document.getElementById('cc-none').style.display='block'; } } """ def _page(title: str, body: str) -> str: return f""" {title} — Outline Sync

Outline Sync

{body}
""" # --------------------------------------------------------------------------- # Phase B — Dashboard # --------------------------------------------------------------------------- def _sync_entry_html(entry: Optional[dict]) -> str: """Render a compact last-sync result block for the dashboard.""" if not entry: return "" err = "error" in entry.get("status", "").lower() status_color = "#dc3545" if err else "#198754" return ( f'{entry["timestamp"]}
' f'{entry.get("files","?")} file(s) ' f'{entry.get("status","?")}' ) @app.get("/", response_class=HTMLResponse) async def dashboard(): s = _get_vault_status() badge = _BADGE.get(s["vault_status"], s["vault_status"]) warn = "" if s["conflicts"] > 0: warn = f'
⚠ {s["conflicts"]} conflict(s) — resolve before pushing. Resolve →
' pending = s["pending_count"] push_label = f"Send to Outline ({pending} pending)" if pending else "Send to Outline" push_dis = " disabled" if s["conflicts"] > 0 else "" pull_html = _sync_entry_html(s["last_pull"]) push_html = _sync_entry_html(s["last_push"]) body = f"""

Vault Status

{warn}
{badge}
{pending}
{pull_html}
{push_html}
Preview Changes
""" return HTMLResponse(_page("Dashboard", body)) @app.get("/status") async def vault_status(): s = _get_vault_status() # Flatten last_pull / last_push to timestamps for backward-compat JSON consumers return JSONResponse({ **s, "last_pull": s["last_pull"]["timestamp"] if s["last_pull"] else None, "last_push": s["last_push"]["timestamp"] if s["last_push"] else None, }) # --------------------------------------------------------------------------- # Phase C/E — Pull & Push # --------------------------------------------------------------------------- @app.post("/pull") async def start_pull(): if _active_job is not None: raise HTTPException(status_code=409, detail="A sync job is already running") job_id = _new_job("pull") return {"job_id": job_id, "stream_url": f"/stream/{job_id}"} @app.post("/push") async def start_push(): if _active_job is not None: raise HTTPException(status_code=409, detail="A sync job is already running") conflicts = _get_conflict_files() if conflicts: return JSONResponse( status_code=409, content={ "detail": "Unresolved conflicts must be resolved before pushing", "conflicts": conflicts, "message": "Resolve conflicts before pushing", }, ) job_id = _new_job("push") return {"job_id": job_id, "stream_url": f"/stream/{job_id}"} @app.get("/stream/{job_id}") async def stream_job(job_id: str): if job_id not in _jobs: raise HTTPException(status_code=404, detail="Job not found") job = _jobs[job_id] async def _generate(): global _active_job # Start the job the moment the first client connects to the stream. if job["status"] == "pending": job["status"] = "running" try: await run_sync_job(job_id, job["command"]) except Exception as exc: job["output"].append({"type": "done", "success": False, "message": str(exc)}) job["status"] = "error" finally: _active_job = None # Stream all buffered output (job already ran inline above). for event in job["output"]: yield f"data: {json.dumps(event)}\n\n" if event.get("type") == "done": return # Fallback: if job was already running when we connected, poll for new output. cursor = 0 while True: buf = job.get("output", []) while cursor < len(buf): yield f"data: {json.dumps(buf[cursor])}\n\n" if buf[cursor].get("type") == "done": return cursor += 1 if job.get("status") in ("done", "error") and cursor >= len(buf): return await asyncio.sleep(0.05) return StreamingResponse( _generate(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) # --------------------------------------------------------------------------- # Phase D — Pending Changes & Diff # --------------------------------------------------------------------------- @app.get("/changes") async def changes(request: Request): items = _get_pending_changes() if "text/html" not in request.headers.get("accept", ""): return JSONResponse(items) if not items: rows = 'No pending changes — vault is in sync.' else: rows = "" for item in items: st = item["status"] tag = f'{st}' path = item["path"] safe = re.sub(r"[^a-zA-Z0-9]", "_", path) enc = base64.urlsafe_b64encode(path.encode()).decode().rstrip("=") display = f'{item.get("from_path","")} → {item.get("to_path",path)}' if st == "renamed" else path action = item.get("action", "") if st == "deleted" and action == "skip": act_cell = f'skip {item.get("reason","")}' else: act_cell = action diff_btn = "" if st == "modified": diff_btn = f'
preview diff' rows += f"{display}{diff_btn}{tag}{act_cell}" body = f"""

Pending Changes ({len(items)})

{rows}
FileStatusAction
""" return HTMLResponse(_page("Pending Changes", body)) @app.get("/diff/{encoded_path}", response_class=HTMLResponse) async def get_diff(encoded_path: str): try: padded = encoded_path + "=" * (-len(encoded_path) % 4) path = base64.urlsafe_b64decode(padded.encode()).decode() except Exception: raise HTTPException(status_code=400, detail="Invalid path encoding") if ".." in path or path.startswith("/"): raise HTTPException(status_code=400, detail="Invalid path") r_outline = _git("show", f"outline:{path}") outline_text = r_outline.stdout if r_outline.returncode == 0 else "" r_main = _git("show", f"HEAD:{path}") if r_main.returncode != 0: raise HTTPException(status_code=404, detail="File not found in main branch") return HTMLResponse(_render_diff_html(outline_text, r_main.stdout, Path(path).name)) # --------------------------------------------------------------------------- # Phase F — Conflict Resolution # --------------------------------------------------------------------------- class ResolveRequest(BaseModel): file: str accept: str @field_validator("accept") @classmethod def _check_accept(cls, v: str) -> str: if v not in ("local", "remote"): raise ValueError("accept must be 'local' or 'remote'") return v @field_validator("file") @classmethod def _check_file(cls, v: str) -> str: if ".." in v or v.startswith("/"): raise ValueError("Path traversal not allowed") return v @app.get("/conflicts") async def list_conflicts(request: Request): conflict_paths = _get_conflict_files() if "text/html" not in request.headers.get("accept", ""): return JSONResponse([{"path": p} for p in conflict_paths]) if not conflict_paths: inner = '

All conflicts resolved. You can now push.

Back to Dashboard' cc_none_display, cc_list_display = "block", "none" cards = "" else: cc_none_display, cc_list_display = "none", "block" cards = "" for path in conflict_paths: safe = re.sub(r"[^a-zA-Z0-9]", "_", path) enc = base64.urlsafe_b64encode(path.encode()).decode().rstrip("=") cards += f"""

{path}

Show Diff
""" inner = "" body = f"""

Version Conflicts ({len(conflict_paths)})

Same document edited in both Obsidian and Outline.

{inner if not conflict_paths else '

All conflicts resolved.

Back to Dashboard'}
{cards}
""" return HTMLResponse(_page("Conflicts", body)) @app.post("/resolve") async def resolve_conflict(req: ResolveRequest): conflict_paths = _get_conflict_files() if req.file not in conflict_paths: raise HTTPException(status_code=404, detail=f"'{req.file}' is not in the conflict list") side = "--ours" if req.accept == "local" else "--theirs" r = _git("checkout", side, req.file) if r.returncode != 0: raise HTTPException(status_code=500, detail=f"git checkout failed: {r.stderr.strip()}") _git("add", req.file) _git("commit", "-m", f"resolve({req.accept}): {req.file}", "--author", "Outline Sync UI ") return {"ok": True, "file": req.file, "accepted": req.accept} # --------------------------------------------------------------------------- # Phase G — Sync History # --------------------------------------------------------------------------- @app.get("/history") async def sync_history(request: Request): entries = _parse_sync_log() fmt = request.query_params.get("format") if fmt == "json": return JSONResponse(entries) if "application/json" in request.headers.get("accept", "") \ and "text/html" not in request.headers.get("accept", ""): return JSONResponse(entries) if not entries: table_body = '

No sync history yet.

' else: rows = "" for e in entries: err = "error" in e.get("status", "").lower() st_style = ' style="color:#dc3545;font-weight:600"' if err else "" icon = "↓ pull" if e.get("direction") == "pull" else "↑ push" rows += f"{e.get('timestamp','—')}{icon}{e.get('files','—')}{e.get('status','—')}" table_body = f"{rows}
TimestampDirectionFiles ChangedStatus
" body = f'

Sync History

{table_body}
' return HTMLResponse(_page("History", body)) # --------------------------------------------------------------------------- # File browser # --------------------------------------------------------------------------- @app.get("/files", response_class=HTMLResponse) async def file_browser(): tree_html = _get_vault_tree_html() # Count files (excluding .git) file_count = 0 if VAULT_DIR.exists(): file_count = sum( 1 for p in VAULT_DIR.rglob("*") if p.is_file() and ".git" not in p.parts ) body = f"""

Vault Files ({file_count})

{VAULT_DIR}
{tree_html}
""" return HTMLResponse(_page("Files", body)) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": import uvicorn uvicorn.run("webui:app", host="0.0.0.0", port=8080, reload=False)