feat: add two-instance compare review UI (Phase 2)
All checks were successful
Deploy / deploy (push) Successful in 12s

- outline_sync.py: write compare results to /tmp/compare_results.json
  with full doc maps for parent-chain lookup during copy operations
- webui.py: /review page with remote-only, local-only, conflict tables;
  /review/content endpoint to fetch doc text from either instance;
  /review/apply endpoint for copy_to_local, copy_to_remote, keep_remote,
  keep_local, skip actions; "Compare Instances" button on dashboard;
  "Review Differences" link shown after compare completes
- entrypoint.sh: include local block in generated settings.json
- docker-compose.yml: pass LOCAL_OUTLINE_URL/TOKEN/HOST env vars
- deploy.yml: write LOCAL_* secrets to .env file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 15:56:16 +01:00
parent 43a7fda692
commit 3a5593f861
5 changed files with 421 additions and 5 deletions

369
webui.py
View File

@@ -22,6 +22,8 @@ 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
@@ -50,6 +52,18 @@ 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
@@ -264,10 +278,21 @@ async def run_sync_job(job_id: str, command: str) -> None:
_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 job["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:
@@ -410,6 +435,14 @@ async function doSync(endpoint, label) {
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;
@@ -506,6 +539,7 @@ async def dashboard():
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">
@@ -533,6 +567,7 @@ async def dashboard():
<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>"""
@@ -641,6 +676,340 @@ async def stream_job(job_id: str):
)
# ---------------------------------------------------------------------------
# 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}" style="background:#f8f9fa;padding:8px;border-radius:4px;font-size:.8rem;white-space:pre-wrap;max-height:300px;overflow:auto">
<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}" style="background:#f8f9fa;padding:8px;border-radius:4px;font-size:.8rem;white-space:pre-wrap;max-height:300px;overflow:auto">
<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>
let resolved = 0;
const total = {total};
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 = '<pre style="background:#f8f9fa;padding:8px;border-radius:4px;font-size:.78rem;white-space:pre-wrap;max-height:300px;overflow:auto">' + escHtml(d.text || '(empty)') + '</pre>';
}}
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()),
]);
document.getElementById('rc-' + enc).textContent = rr.text || '(empty)';
document.getElementById('lc-' + enc).textContent = lr.text || '(empty)';
}}
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'));
}}
}}
function escHtml(s) {{
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}}
</script>"""
return HTMLResponse(_page(body, "Review Differences"))
@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
# ---------------------------------------------------------------------------