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

View File

@@ -47,6 +47,7 @@ jobs:
OUTLINE_TOKEN=${{ secrets.OUTLINE_TOKEN }}
LOCAL_OUTLINE_URL=${{ secrets.LOCAL_OUTLINE_URL }}
LOCAL_OUTLINE_TOKEN=${{ secrets.LOCAL_OUTLINE_TOKEN }}
LOCAL_OUTLINE_HOST=${{ secrets.LOCAL_OUTLINE_HOST }}
TS_AUTHKEY=${{ secrets.TS_AUTHKEY }}
EOF

View File

@@ -47,6 +47,7 @@ services:
- OUTLINE_TOKEN=${OUTLINE_TOKEN}
- LOCAL_OUTLINE_URL=${LOCAL_OUTLINE_URL:-}
- LOCAL_OUTLINE_TOKEN=${LOCAL_OUTLINE_TOKEN:-}
- LOCAL_OUTLINE_HOST=${LOCAL_OUTLINE_HOST:-}
volumes:
tailscale-state:

View File

@@ -8,7 +8,7 @@ cat > /work/settings.json <<EOF
{
"source": { "url": "${OUTLINE_URL}", "token": "${OUTLINE_TOKEN}" },
"target": { "url": "${OUTLINE_URL}", "token": "${OUTLINE_TOKEN}" },
"local": { "url": "${LOCAL_OUTLINE_URL:-}", "token": "${LOCAL_OUTLINE_TOKEN:-}" },
"local": { "url": "${LOCAL_OUTLINE_URL:-}", "token": "${LOCAL_OUTLINE_TOKEN:-}", "host": "${LOCAL_OUTLINE_HOST:-}" },
"sync": { "allow_deletions": false }
}
EOF

View File

@@ -128,7 +128,7 @@ def sanitize_name(name: str, max_len: int = 200) -> str:
class OutlineSync:
def __init__(self, base_url: str, api_token: str, vault_dir: Path):
def __init__(self, base_url: str, api_token: str, vault_dir: Path, host_header: str = ""):
self.base_url = base_url.rstrip("/")
self.api_token = api_token
self.vault_dir = Path(vault_dir)
@@ -146,6 +146,8 @@ class OutlineSync:
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
if host_header:
self.headers["Host"] = host_header
self._doc_cache: Dict[str, Dict] = {}
self.stats = {"collections": 0, "documents": 0, "errors": 0}
@@ -701,7 +703,7 @@ class OutlineSync:
self._collect_docs_keyed(tree, coll["name"], out)
return out
def cmd_compare(self, local_url: str, local_token: str) -> bool:
def cmd_compare(self, local_url: str, local_token: str, local_host: str = "") -> bool:
"""
Fetch both the remote and local Outline instances and print a diff report.
No writes to either instance.
@@ -721,7 +723,7 @@ class OutlineSync:
print(f"{len(remote_docs)} documents\n")
print("Fetching local instance (outline-web)...")
local_client = OutlineSync(local_url, local_token, self.vault_dir)
local_client = OutlineSync(local_url, local_token, self.vault_dir, host_header=local_host)
if not local_client.health_check(label="local"):
print("✗ Cannot reach local Outline API — aborting.")
return False
@@ -791,6 +793,47 @@ class OutlineSync:
print(f" ~ {key}")
print(f" remote: {r_ts} local: {l_ts}")
# Write structured results for the review UI
results_data = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"remote_url": self.base_url,
"local_url": local_url,
"local_host": local_host,
"summary": {
"in_sync": in_sync,
"remote_only": remote_only,
"local_only": local_only,
"conflicts": conflicts,
},
"entries": {
"remote_only": [
{"key": k, "title": remote_docs[k]["title"],
"id": remote_docs[k]["id"], "ts": remote_docs[k]["updatedAt"]}
for k in results["remote_only"]
],
"local_only": [
{"key": k, "title": local_docs[k]["title"],
"id": local_docs[k]["id"], "ts": local_docs[k]["updatedAt"]}
for k in results["local_only"]
],
"conflicts": [
{"key": key, "title": r["title"],
"remote_id": r["id"], "remote_ts": r["updatedAt"],
"local_id": l["id"], "local_ts": l["updatedAt"]}
for key, r, l in results["conflict"]
],
},
# Full doc maps for parent-chain lookup during copy
"all_remote": remote_docs,
"all_local": local_docs,
}
try:
Path("/tmp/compare_results.json").write_text(
json.dumps(results_data), encoding="utf-8"
)
except Exception as exc:
logger.warning("Could not write compare results: %s", exc)
return True
# ── Commands ──────────────────────────────────────────────────────────────
@@ -885,6 +928,7 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--token", help="Remote API token (overrides settings.source.token)")
p.add_argument("--local-url", help="Local Outline API URL (overrides settings.local.url)")
p.add_argument("--local-token", help="Local API token (overrides settings.local.token)")
p.add_argument("--local-host", help="Host header for local instance (overrides settings.local.host)")
p.add_argument(
"-v", "--verbose",
action="count",
@@ -931,13 +975,14 @@ def main() -> None:
elif args.command == "compare":
local_url = args.local_url or local.get("url")
local_token = args.local_token or local.get("token")
local_host = args.local_host or local.get("host", "")
if not local_url or not local_token:
logger.error(
"Missing local API URL or token — set local.url and local.token "
"in settings.json, or pass --local-url / --local-token."
)
sys.exit(1)
ok = sync.cmd_compare(local_url, local_token)
ok = sync.cmd_compare(local_url, local_token, local_host)
else:
ok = False
sys.exit(0 if ok else 1)

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
# ---------------------------------------------------------------------------