All checks were successful
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1262 lines
50 KiB
Python
1262 lines
50 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
|
||
|
||
import requests as _requests
|
||
|
||
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
|
||
|
||
_compare_state: dict = {} # last compare results (loaded from /tmp/compare_results.json)
|
||
_COMPARE_RESULTS = Path("/tmp/compare_results.json")
|
||
|
||
|
||
def _load_compare_state() -> dict:
|
||
try:
|
||
if _COMPARE_RESULTS.exists():
|
||
return json.loads(_COMPARE_RESULTS.read_text())
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
# After a successful compare, load structured results for the review UI
|
||
review_url = ""
|
||
if command == "compare" and success:
|
||
global _compare_state
|
||
_compare_state = _load_compare_state()
|
||
if _compare_state.get("summary", {}).get("in_sync") is not None:
|
||
total = sum(_compare_state["summary"].get(k, 0)
|
||
for k in ("remote_only", "local_only", "conflicts"))
|
||
if total:
|
||
review_url = "/review"
|
||
_jobs[job_id]["output"].append({
|
||
"type": "done",
|
||
"success": success,
|
||
"message": summary_line or ("Sync completed." if success else "Sync failed."),
|
||
"review_url": review_url,
|
||
})
|
||
_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}
|
||
.md-preview{background:#f8f9fa;border-radius:4px;padding:12px 16px;max-height:340px;overflow:auto;font-size:.85rem;line-height:1.6}
|
||
.md-preview h1,.md-preview h2,.md-preview h3{margin:.6em 0 .3em;font-size:1em;font-weight:700}
|
||
.md-preview p{margin:.4em 0}
|
||
.md-preview ul,.md-preview ol{padding-left:1.4em;margin:.4em 0}
|
||
.md-preview code{background:#e9ecef;padding:1px 5px;border-radius:3px;font-size:.9em}
|
||
.md-preview pre{background:#e9ecef;padding:10px;border-radius:4px;overflow:auto}
|
||
.md-preview pre code{background:none;padding:0}
|
||
.md-preview blockquote{border-left:3px solid #ccc;margin:.4em 0;padding:.2em .8em;color:#666}
|
||
.md-preview a{color:#0066cc}
|
||
.md-preview table{width:auto;font-size:.85em}
|
||
"""
|
||
|
||
_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) {}
|
||
}
|
||
|
||
let _activeSrc = null;
|
||
async function doSync(endpoint, label) {
|
||
if (_activeSrc) { _activeSrc.close(); _activeSrc = null; }
|
||
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);
|
||
_activeSrc = src;
|
||
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);
|
||
if (ev.review_url) {
|
||
const a = document.createElement('a');
|
||
a.href = ev.review_url;
|
||
a.className = 'btn btn-primary';
|
||
a.style = 'margin-top:8px;display:inline-block';
|
||
a.textContent = 'Review Differences →';
|
||
panel.appendChild(a);
|
||
}
|
||
panel.scrollTop = panel.scrollHeight;
|
||
src.close();
|
||
_activeSrc = null;
|
||
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>'
|
||
local_host = os.environ.get("LOCAL_OUTLINE_HOST", "") or '<span style="color:#999">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>LOCAL_OUTLINE_HOST</td><td><code>{local_host}</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"},
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Compare Review
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _outline_api(url: str, token: str, endpoint: str, data: dict, host: str = "") -> Optional[dict]:
|
||
"""Single Outline API call used by the review endpoints."""
|
||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||
if host:
|
||
headers["Host"] = host
|
||
try:
|
||
r = _requests.post(f"{url}{endpoint}", headers=headers, json=data, timeout=30)
|
||
if r.status_code == 200:
|
||
return r.json()
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _sanitize(name: str) -> str:
|
||
import re as _re
|
||
name = _re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
|
||
name = _re.sub(r"\s+", " ", name).strip()
|
||
return name[:200] if name else "Untitled"
|
||
|
||
|
||
def _copy_document(src_url, src_token, src_host, dst_url, dst_token, dst_host,
|
||
doc_id: str, match_key: str, title: str,
|
||
all_dst_docs: dict) -> tuple[bool, str]:
|
||
"""Fetch doc from src, create it on dst. Returns (ok, message)."""
|
||
# 1. Fetch content from source
|
||
full = _outline_api(src_url, src_token, "/api/documents.info", {"id": doc_id}, src_host)
|
||
if not full:
|
||
return False, "Could not fetch source document"
|
||
text = full["data"].get("text", "")
|
||
title = full["data"].get("title", title)
|
||
|
||
# 2. Find collection on destination (by sanitized name)
|
||
colls_resp = _outline_api(dst_url, dst_token, "/api/collections.list", {}, dst_host)
|
||
if not colls_resp:
|
||
return False, "Could not list collections on target"
|
||
key_parts = match_key.split("/")
|
||
coll_key = key_parts[0]
|
||
coll_map = {_sanitize(c["name"]).lower(): c for c in colls_resp.get("data", [])}
|
||
coll = coll_map.get(coll_key)
|
||
if not coll:
|
||
r = _outline_api(dst_url, dst_token, "/api/collections.create",
|
||
{"name": coll_key.title(), "private": False}, dst_host)
|
||
if not r:
|
||
return False, f"Could not create collection '{coll_key}'"
|
||
coll = r["data"]
|
||
coll_id = coll["id"]
|
||
|
||
# 3. Resolve parent on destination (best-effort)
|
||
parent_id = None
|
||
if len(key_parts) > 2:
|
||
parent_key = "/".join(key_parts[:-1])
|
||
parent_meta = all_dst_docs.get(parent_key)
|
||
if parent_meta:
|
||
parent_id = parent_meta["id"]
|
||
|
||
# 4. Create document
|
||
payload = {"title": title, "text": text, "collectionId": coll_id, "publish": True}
|
||
if parent_id:
|
||
payload["parentDocumentId"] = parent_id
|
||
r = _outline_api(dst_url, dst_token, "/api/documents.create", payload, dst_host)
|
||
if not r:
|
||
return False, "Could not create document on target"
|
||
return True, r["data"]["id"]
|
||
|
||
|
||
@app.get("/review", response_class=HTMLResponse)
|
||
async def review_page():
|
||
state = _compare_state or _load_compare_state()
|
||
if not state:
|
||
return HTMLResponse("<p>No compare results. Run <a href='/'>Compare Instances</a> first.</p>")
|
||
|
||
summary = state.get("summary", {})
|
||
entries = state.get("entries", {})
|
||
r_only = entries.get("remote_only", [])
|
||
l_only = entries.get("local_only", [])
|
||
conflicts= entries.get("conflicts", [])
|
||
total = summary.get("remote_only", 0) + summary.get("local_only", 0) + summary.get("conflicts", 0)
|
||
|
||
def row(entry: dict, action: str, label: str, side: str) -> str:
|
||
key = entry["key"]
|
||
title = entry.get("title", key.split("/")[-1])
|
||
doc_id = entry.get("id") or entry.get("remote_id" if side == "remote" else "local_id", "")
|
||
ts = (entry.get("ts") or entry.get("remote_ts" if side == "remote" else "local_ts", ""))[:19]
|
||
enc = base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||
return f"""
|
||
<tr id="row-{enc}">
|
||
<td style="padding:8px 4px">
|
||
<code style="font-size:.8rem;color:#666">{key}</code><br>
|
||
<strong>{title}</strong>
|
||
<div id="content-{enc}" style="display:none;margin-top:8px"></div>
|
||
</td>
|
||
<td style="white-space:nowrap;color:#888;font-size:.8rem">{ts}</td>
|
||
<td style="white-space:nowrap">
|
||
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px"
|
||
onclick="loadContent('{enc}','{doc_id}','{side}')">View</button>
|
||
<button class="btn btn-primary" style="font-size:.8rem;padding:3px 8px"
|
||
onclick="applyAction('{enc}','{action}')">✓ {label}</button>
|
||
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px;opacity:.6"
|
||
onclick="applyAction('{enc}','skip')">Skip</button>
|
||
</td>
|
||
</tr>"""
|
||
|
||
def conflict_row(entry: dict) -> str:
|
||
key = entry["key"]
|
||
enc = base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||
r_ts = entry.get("remote_ts", "")[:19]
|
||
l_ts = entry.get("local_ts", "")[:19]
|
||
return f"""
|
||
<tr id="row-{enc}">
|
||
<td style="padding:8px 4px">
|
||
<code style="font-size:.8rem;color:#666">{key}</code>
|
||
<div style="display:flex;gap:8px;margin-top:8px">
|
||
<div style="flex:1">
|
||
<div style="font-size:.75rem;color:#888;margin-bottom:4px">remote · {r_ts}</div>
|
||
<div id="rc-{enc}" class="md-preview">
|
||
<em style="color:#999">click View to load</em></div>
|
||
</div>
|
||
<div style="flex:1">
|
||
<div style="font-size:.75rem;color:#888;margin-bottom:4px">local · {l_ts}</div>
|
||
<div id="lc-{enc}" class="md-preview">
|
||
<em style="color:#999">click View to load</em></div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td style="white-space:nowrap;vertical-align:top">
|
||
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px"
|
||
onclick="loadConflict('{enc}','{entry['remote_id']}','{entry['local_id']}')">View</button>
|
||
<button class="btn btn-primary" style="font-size:.8rem;padding:3px 8px"
|
||
onclick="applyAction('{enc}','keep_remote')">Keep Remote</button>
|
||
<button class="btn btn-success" style="font-size:.8rem;padding:3px 8px"
|
||
onclick="applyAction('{enc}','keep_local')">Keep Local</button>
|
||
<button class="btn btn-secondary" style="font-size:.8rem;padding:3px 8px;opacity:.6"
|
||
onclick="applyAction('{enc}','skip')">Skip</button>
|
||
</td>
|
||
</tr>"""
|
||
|
||
r_rows = "".join(row(e, "copy_to_local", "Copy to local", "remote") for e in r_only)
|
||
l_rows = "".join(row(e, "copy_to_remote", "Copy to remote", "local") for e in l_only)
|
||
c_rows = "".join(conflict_row(e) for e in conflicts)
|
||
|
||
r_section = f"""
|
||
<div class="card" style="margin-top:12px">
|
||
<h3>Remote only — {len(r_only)} documents</h3>
|
||
<p style="color:#888;font-size:.85rem">Exist on remote, missing on local. Copy to local or skip.</p>
|
||
<table style="width:100%"><thead><tr><th>Document</th><th>Updated</th><th>Action</th></tr></thead>
|
||
<tbody>{r_rows}</tbody></table>
|
||
</div>""" if r_only else ""
|
||
|
||
l_section = f"""
|
||
<div class="card" style="margin-top:12px">
|
||
<h3>Local only — {len(l_only)} documents</h3>
|
||
<p style="color:#888;font-size:.85rem">Exist on local, missing on remote. Copy to remote or skip.</p>
|
||
<table style="width:100%"><thead><tr><th>Document</th><th>Updated</th><th>Action</th></tr></thead>
|
||
<tbody>{l_rows}</tbody></table>
|
||
</div>""" if l_only else ""
|
||
|
||
c_section = f"""
|
||
<div class="card" style="margin-top:12px">
|
||
<h3>Conflicts — {len(conflicts)} documents</h3>
|
||
<p style="color:#888;font-size:.85rem">Exist on both with different content.</p>
|
||
<table style="width:100%"><thead><tr><th>Document</th><th>Action</th></tr></thead>
|
||
<tbody>{c_rows}</tbody></table>
|
||
</div>""" if conflicts else ""
|
||
|
||
ts = state.get("timestamp", "")[:19]
|
||
body = f"""
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<h2 style="margin:0">Review Differences</h2>
|
||
<span style="color:#888;font-size:.85rem">compared {ts}</span>
|
||
</div>
|
||
<div style="display:flex;gap:16px;margin-top:12px;flex-wrap:wrap">
|
||
<span>✓ <strong>{summary.get('in_sync',0)}</strong> in sync</span>
|
||
<span>→ <strong>{summary.get('remote_only',0)}</strong> remote only</span>
|
||
<span>← <strong>{summary.get('local_only',0)}</strong> local only</span>
|
||
<span>⚡ <strong>{summary.get('conflicts',0)}</strong> conflicts</span>
|
||
</div>
|
||
<div style="margin-top:12px">
|
||
<strong id="pending-count">{total}</strong> items to resolve
|
||
<span id="resolved-count" style="color:#28a745;margin-left:12px"></span>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<a href="/" class="btn btn-secondary">← Back</a>
|
||
</div>
|
||
</div>
|
||
{r_section}
|
||
{l_section}
|
||
{c_section}
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
|
||
<script>
|
||
let resolved = 0;
|
||
const total = {total};
|
||
|
||
function renderMd(text) {{
|
||
const html = marked.parse(text || '*(empty)*');
|
||
const el = document.createElement('div');
|
||
el.className = 'md-preview';
|
||
el.innerHTML = html;
|
||
return el;
|
||
}}
|
||
|
||
async function loadContent(enc, docId, instance) {{
|
||
const el = document.getElementById('content-' + enc);
|
||
el.style.display = 'block';
|
||
el.innerHTML = '<em style="color:#999">Loading…</em>';
|
||
const r = await fetch('/review/content?doc_id=' + docId + '&instance=' + instance);
|
||
const d = await r.json();
|
||
el.innerHTML = '';
|
||
el.appendChild(renderMd(d.text));
|
||
}}
|
||
|
||
async function loadConflict(enc, remoteId, localId) {{
|
||
const [rr, lr] = await Promise.all([
|
||
fetch('/review/content?doc_id=' + remoteId + '&instance=remote').then(r=>r.json()),
|
||
fetch('/review/content?doc_id=' + localId + '&instance=local' ).then(r=>r.json()),
|
||
]);
|
||
const rc = document.getElementById('rc-' + enc);
|
||
const lc = document.getElementById('lc-' + enc);
|
||
rc.innerHTML = ''; rc.appendChild(renderMd(rr.text));
|
||
lc.innerHTML = ''; lc.appendChild(renderMd(lr.text));
|
||
}}
|
||
|
||
async function applyAction(enc, action) {{
|
||
const row = document.getElementById('row-' + enc);
|
||
const btns = row.querySelectorAll('button');
|
||
btns.forEach(b => b.disabled = true);
|
||
const r = await fetch('/review/apply', {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify({{enc, action}}),
|
||
}});
|
||
const d = await r.json();
|
||
if (d.ok) {{
|
||
row.style.opacity = '0.4';
|
||
row.querySelector('td:last-child').innerHTML = '<span style="color:#28a745">✓ ' + (d.message||'done') + '</span>';
|
||
resolved++;
|
||
document.getElementById('resolved-count').textContent = resolved + ' resolved';
|
||
document.getElementById('pending-count').textContent = (total - resolved) + ' remaining';
|
||
}} else {{
|
||
btns.forEach(b => b.disabled = false);
|
||
alert('Error: ' + (d.error || 'unknown'));
|
||
}}
|
||
}}
|
||
</script>"""
|
||
|
||
return HTMLResponse(_page("Review Differences", body))
|
||
|
||
|
||
@app.get("/review/content")
|
||
async def review_content(doc_id: str, instance: str):
|
||
state = _compare_state or _load_compare_state()
|
||
settings = _load_settings()
|
||
if instance == "remote":
|
||
url, token, host = (settings.get("source", {}).get("url", ""),
|
||
settings.get("source", {}).get("token", ""), "")
|
||
else:
|
||
local = settings.get("local", {})
|
||
url, token, host = local.get("url", ""), local.get("token", ""), local.get("host", "")
|
||
result = await asyncio.to_thread(
|
||
_outline_api, url, token, "/api/documents.info", {"id": doc_id}, host
|
||
)
|
||
if result and "data" in result:
|
||
return JSONResponse({"text": result["data"].get("text", ""),
|
||
"title": result["data"].get("title", "")})
|
||
return JSONResponse({"error": "fetch failed", "text": ""}, status_code=500)
|
||
|
||
|
||
@app.post("/review/apply")
|
||
async def review_apply(request: Request):
|
||
body = await request.json()
|
||
enc = body.get("enc", "")
|
||
action = body.get("action", "")
|
||
settings = _load_settings()
|
||
state = _compare_state or _load_compare_state()
|
||
entries = state.get("entries", {})
|
||
|
||
if action == "skip":
|
||
return JSONResponse({"ok": True, "message": "skipped"})
|
||
|
||
# Decode key
|
||
try:
|
||
padding = "=" * (-len(enc) % 4)
|
||
key = base64.urlsafe_b64decode(enc + padding).decode()
|
||
except Exception:
|
||
return JSONResponse({"ok": False, "error": "bad key"}, status_code=400)
|
||
|
||
remote_cfg = settings.get("source", {})
|
||
local_cfg = settings.get("local", {})
|
||
r_url, r_tok, r_host = remote_cfg.get("url",""), remote_cfg.get("token",""), ""
|
||
l_url, l_tok, l_host = local_cfg.get("url",""), local_cfg.get("token",""), local_cfg.get("host","")
|
||
|
||
if action == "copy_to_local":
|
||
entry = next((e for e in entries.get("remote_only",[]) if e["key"] == key), None)
|
||
if not entry:
|
||
return JSONResponse({"ok": False, "error": "entry not found"})
|
||
ok, msg = await asyncio.to_thread(
|
||
_copy_document,
|
||
r_url, r_tok, r_host, l_url, l_tok, l_host,
|
||
entry["id"], key, entry["title"], state.get("all_local", {})
|
||
)
|
||
return JSONResponse({"ok": ok, "message": "copied to local" if ok else None, "error": msg if not ok else None})
|
||
|
||
if action == "copy_to_remote":
|
||
entry = next((e for e in entries.get("local_only",[]) if e["key"] == key), None)
|
||
if not entry:
|
||
return JSONResponse({"ok": False, "error": "entry not found"})
|
||
ok, msg = await asyncio.to_thread(
|
||
_copy_document,
|
||
l_url, l_tok, l_host, r_url, r_tok, r_host,
|
||
entry["id"], key, entry["title"], state.get("all_remote", {})
|
||
)
|
||
return JSONResponse({"ok": ok, "message": "copied to remote" if ok else None, "error": msg if not ok else None})
|
||
|
||
if action in ("keep_remote", "keep_local"):
|
||
entry = next((e for e in entries.get("conflicts",[]) if e["key"] == key), None)
|
||
if not entry:
|
||
return JSONResponse({"ok": False, "error": "entry not found"})
|
||
if action == "keep_remote":
|
||
# Push remote content to local
|
||
ok, msg = await asyncio.to_thread(
|
||
_copy_document,
|
||
r_url, r_tok, r_host, l_url, l_tok, l_host,
|
||
entry["remote_id"], key, entry["title"], state.get("all_local", {})
|
||
)
|
||
else:
|
||
# Push local content to remote
|
||
ok, msg = await asyncio.to_thread(
|
||
_copy_document,
|
||
l_url, l_tok, l_host, r_url, r_tok, r_host,
|
||
entry["local_id"], key, entry["title"], state.get("all_remote", {})
|
||
)
|
||
return JSONResponse({"ok": ok, "message": "applied" if ok else None, "error": msg if not ok else None})
|
||
|
||
return JSONResponse({"ok": False, "error": f"unknown action: {action}"}, status_code=400)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|