All checks were successful
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
871 lines
33 KiB
Python
871 lines
33 KiB
Python
#!/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 io
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import uuid
|
||
import zipfile
|
||
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"))
|
||
def _read_version() -> str:
|
||
v = os.environ.get("APP_VERSION", "")
|
||
if v:
|
||
return v
|
||
vf = Path(__file__).parent / "VERSION"
|
||
return vf.read_text().strip() if vf.exists() else "dev"
|
||
|
||
APP_VERSION: str = _read_version()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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'<ul style="margin:0;padding-left:{indent}px;list-style:none">'
|
||
for item in items:
|
||
if item.name == ".git":
|
||
continue
|
||
if item.is_dir():
|
||
open_attr = " open" if depth == 0 else ""
|
||
html += (
|
||
f'<li><details{open_attr}>'
|
||
f'<summary style="cursor:pointer;padding:2px 0;user-select:none">'
|
||
f'<strong style="color:#444">{item.name}/</strong></summary>'
|
||
f'{_build_tree_html(item, depth + 1)}</details></li>'
|
||
)
|
||
else:
|
||
try:
|
||
sz = item.stat().st_size
|
||
size_str = f"{sz / 1024:.1f} KB" if sz >= 1024 else f"{sz} B"
|
||
except OSError:
|
||
size_str = "?"
|
||
html += (
|
||
f'<li style="padding:2px 0">'
|
||
f'<code style="font-size:.85rem">{item.name}</code> '
|
||
f'<small style="color:#aaa">{size_str}</small></li>'
|
||
)
|
||
html += "</ul>"
|
||
return html
|
||
|
||
|
||
def _get_vault_tree_html() -> str:
|
||
if not VAULT_DIR.exists():
|
||
return "<em style='color:#999'>Vault directory not found.</em>"
|
||
try:
|
||
return _build_tree_html(VAULT_DIR)
|
||
except Exception as exc:
|
||
return f"<em style='color:#dc3545'>Error reading vault: {exc}</em>"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Async subprocess + job runner
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def spawn_sync_subprocess(command: str) -> asyncio.subprocess.Process:
|
||
"""
|
||
Run outline_sync.py <command> 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()
|
||
if text.startswith("Done."):
|
||
summary_line = text
|
||
else:
|
||
_jobs[job_id]["output"].append({"type": "log", "message": 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"""<style>
|
||
.diff-wrap{{font-family:monospace;font-size:13px;overflow-x:auto}}
|
||
.diff{{width:100%;border-collapse:collapse}}
|
||
.diff td{{padding:2px 6px;white-space:pre-wrap;word-break:break-all}}
|
||
.diff_header{{background:#e0e0e0;font-weight:bold}}
|
||
td.diff_header{{text-align:right}}
|
||
.diff_next{{background:#c0c0c0}}
|
||
.diff_add{{background:#aaffaa}}
|
||
.diff_chg{{background:#ffff77}}
|
||
.diff_sub{{background:#ffaaaa}}
|
||
</style>
|
||
<div class="diff-wrap"><h4 style="margin:0 0 8px;font-size:14px">Diff: {filename}</h4>{table}</div>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTML helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_BADGE = {
|
||
"clean": '<span class="badge clean">Clean</span>',
|
||
"dirty": '<span class="badge dirty">Pending Changes</span>',
|
||
"conflict": '<span class="badge conflict">Conflicts!</span>',
|
||
}
|
||
|
||
_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 = '<div class="ln">' + label + '…</div>';
|
||
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 = '<em>Loading…</em>';
|
||
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"""<!DOCTYPE html>
|
||
<html lang="en"><head><meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{title} — Outline Sync</title>
|
||
<style>{_BASE_CSS}</style></head>
|
||
<body>
|
||
<header>
|
||
<h1>Outline Sync <small style="font-size:.55em;color:#aaa;font-weight:400">v{APP_VERSION}</small></h1>
|
||
<nav>
|
||
<a href="/">Dashboard</a>
|
||
<a href="/changes">Changes</a>
|
||
<a href="/conflicts">Conflicts</a>
|
||
<a href="/history">History</a>
|
||
<a href="/files">Files</a>
|
||
</nav>
|
||
</header>
|
||
<main>{body}</main>
|
||
<script>{_SCRIPT}</script>
|
||
</body></html>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 "<span style='color:#999'>—</span>"
|
||
err = "error" in entry.get("status", "").lower()
|
||
status_color = "#dc3545" if err else "#198754"
|
||
return (
|
||
f'<span>{entry["timestamp"]}</span><br>'
|
||
f'<small style="color:#666">{entry.get("files","?")} file(s)</small> '
|
||
f'<small style="color:{status_color};font-weight:600">{entry.get("status","?")}</small>'
|
||
)
|
||
|
||
|
||
@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'<div class="alert alert-warn">⚠ {s["conflicts"]} conflict(s) — resolve before pushing. <a href="/conflicts">Resolve →</a></div>'
|
||
|
||
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"])
|
||
|
||
outline_url = os.environ.get("OUTLINE_URL", "")
|
||
outline_tok = os.environ.get("OUTLINE_TOKEN", "") or '<span style="color:#dc3545">NOT SET</span>'
|
||
local_url = os.environ.get("LOCAL_OUTLINE_URL", "") or '<span style="color:#dc3545">NOT SET</span>'
|
||
local_tok = os.environ.get("LOCAL_OUTLINE_TOKEN", "") or '<span style="color:#dc3545">NOT SET</span>'
|
||
|
||
body = f"""
|
||
<div class="card">
|
||
<h2>Vault Status</h2>
|
||
{warn}
|
||
<div class="grid" id="stats-grid">
|
||
<div class="stat"><label>Status</label><span id="stat-status">{badge}</span></div>
|
||
<div class="stat"><label>Pending local changes</label><strong id="stat-pending">{pending}</strong></div>
|
||
<div class="stat"><label>Last pull</label><span id="stat-pull">{pull_html}</span></div>
|
||
<div class="stat"><label>Last push</label><span id="stat-push">{push_html}</span></div>
|
||
</div>
|
||
<div class="row">
|
||
<button class="btn btn-primary" onclick="doSync('/pull','Pulling from Outline')">Get from Outline</button>
|
||
<button class="btn btn-success" onclick="doSync('/push','Sending to Outline')"{push_dis}>{push_label}</button>
|
||
<a href="/changes" class="btn btn-secondary">Preview Changes</a>
|
||
<button class="btn btn-secondary" onclick="doSync('/compare','Comparing instances')">Compare Instances</button>
|
||
</div>
|
||
<div id="output"></div>
|
||
</div>
|
||
<div class="card" style="margin-top:12px">
|
||
<h2>Config</h2>
|
||
<table>
|
||
<tr><th>Key</th><th>Value</th></tr>
|
||
<tr><td>OUTLINE_URL</td><td><code>{outline_url or '<span style="color:#dc3545">NOT SET</span>'}</code></td></tr>
|
||
<tr><td>OUTLINE_TOKEN</td><td><code>{outline_tok}</code></td></tr>
|
||
<tr><td>LOCAL_OUTLINE_URL</td><td><code>{local_url}</code></td></tr>
|
||
<tr><td>LOCAL_OUTLINE_TOKEN</td><td><code>{local_tok}</code></td></tr>
|
||
<tr><td>APP_VERSION</td><td><code>{APP_VERSION}</code></td></tr>
|
||
</table>
|
||
</div>"""
|
||
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("/compare")
|
||
async def start_compare():
|
||
if _active_job is not None:
|
||
raise HTTPException(status_code=409, detail="A sync job is already running")
|
||
settings = _load_settings()
|
||
local = settings.get("local", {})
|
||
if not local.get("url") or not local.get("token"):
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"detail": "Local instance not configured — set LOCAL_OUTLINE_URL and LOCAL_OUTLINE_TOKEN env vars"},
|
||
)
|
||
job_id = _new_job("compare")
|
||
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 = '<tr><td colspan="3" style="color:#999;text-align:center;padding:20px">No pending changes — vault is in sync.</td></tr>'
|
||
else:
|
||
rows = ""
|
||
for item in items:
|
||
st = item["status"]
|
||
tag = f'<span class="tag tag-{st}">{st}</span>'
|
||
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'<span class="tag tag-skip">skip</span> <small style="color:#999">{item.get("reason","")}</small>'
|
||
else:
|
||
act_cell = action
|
||
|
||
diff_btn = ""
|
||
if st == "modified":
|
||
diff_btn = f'<br><a href="#" onclick="toggleDiff(\'{path}\',\'{enc}\');return false" style="font-size:.8rem;color:#0066cc">preview diff</a><div id="diff_{safe}" class="diff-container" style="display:none"></div>'
|
||
|
||
rows += f"<tr><td><code>{display}</code>{diff_btn}</td><td>{tag}</td><td>{act_cell}</td></tr>"
|
||
|
||
body = f"""
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<h2 style="margin:0">Pending Changes ({len(items)})</h2>
|
||
<button class="btn btn-success" onclick="doSync('/push','Sending to Outline')">Send to Outline</button>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>File</th><th>Status</th><th>Action</th></tr></thead>
|
||
<tbody>{rows}</tbody>
|
||
</table>
|
||
<div id="output"></div>
|
||
</div>"""
|
||
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 = '<p style="color:#155724">All conflicts resolved. You can now push.</p><a href="/" class="btn btn-success">Back to Dashboard</a>'
|
||
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"""
|
||
<div class="conflict-card" id="cc_{safe}">
|
||
<h3>{path}</h3>
|
||
<div class="row">
|
||
<a href="#" class="btn btn-secondary" style="font-size:.85rem" onclick="toggleDiff('{path}','{enc}');return false">Show Diff</a>
|
||
<button class="btn btn-primary" onclick="resolve('{path}','local')">Keep Mine</button>
|
||
<button class="btn btn-danger" onclick="resolve('{path}','remote')">Keep Outline's</button>
|
||
</div>
|
||
<div id="diff_{safe}" class="diff-container" style="display:none"></div>
|
||
</div>"""
|
||
inner = ""
|
||
|
||
body = f"""
|
||
<div class="card">
|
||
<h2>Version Conflicts ({len(conflict_paths)})</h2>
|
||
<p style="color:#666;margin-top:0">Same document edited in both Obsidian and Outline.</p>
|
||
<div id="cc-none" style="display:{cc_none_display}">{inner if not conflict_paths else '<p style="color:#155724">All conflicts resolved.</p><a href="/" class="btn btn-success">Back to Dashboard</a>'}</div>
|
||
<div id="cc-list" style="display:{cc_list_display}">{cards}</div>
|
||
</div>"""
|
||
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 <sync@local>")
|
||
|
||
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 = '<p style="color:#999;text-align:center;padding:24px">No sync history yet.</p>'
|
||
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"<tr><td>{e.get('timestamp','—')}</td><td>{icon}</td><td>{e.get('files','—')}</td><td{st_style}>{e.get('status','—')}</td></tr>"
|
||
table_body = f"<table><thead><tr><th>Timestamp</th><th>Direction</th><th>Files Changed</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>"
|
||
|
||
body = f'<div class="card"><h2>Sync History</h2>{table_body}</div>'
|
||
return HTMLResponse(_page("History", body))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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()
|
||
# 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"""
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:.9rem;line-height:1.6">{tree_html}</div>
|
||
</div>"""
|
||
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)
|