Files
outline-sync/webui.py
Claude 7553790b5a
All checks were successful
Deploy / deploy (push) Successful in 12s
fix: keep log panel persistent and scrollable after sync completes
- Remove auto-reload after job completion
- Re-enable button and refresh stats in-place via /status fetch
- Increase log panel max-height to 600px, add resize:vertical
- Add IDs to stat cells for targeted DOM updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:47:19 +01:00

808 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Outline Sync Web UI — FastAPI + HTMX
Phases BG 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'<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()
_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"""<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</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">&#9888; {s["conflicts"]} conflict(s) — resolve before pushing. <a href="/conflicts">Resolve &rarr;</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"])
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>
</div>
<div id="output"></div>
</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("/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", 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>
<small style="color:#999"><code>{VAULT_DIR}</code></small>
</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)