From 3a5593f861e5913938578ade36bd42c965e3d18f Mon Sep 17 00:00:00 2001 From: domverse Date: Fri, 20 Mar 2026 15:56:16 +0100 Subject: [PATCH] feat: add two-instance compare review UI (Phase 2) - 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 --- .gitea/workflows/deploy.yml | 1 + docker-compose.yml | 1 + entrypoint.sh | 2 +- outline_sync.py | 53 +++++- webui.py | 369 ++++++++++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b5223b0..8ac22f9 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f918991..15660ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh index 333aeff..78fee06 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,7 +8,7 @@ cat > /work/settings.json < 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) diff --git a/webui.py b/webui.py index e542eac..c2868b1 100644 --- a/webui.py +++ b/webui.py @@ -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 'NOT SET' local_url = os.environ.get("LOCAL_OUTLINE_URL", "") or 'NOT SET' local_tok = os.environ.get("LOCAL_OUTLINE_TOKEN", "") or 'NOT SET' + local_host = os.environ.get("LOCAL_OUTLINE_HOST", "") or 'not set' body = f"""
@@ -533,6 +567,7 @@ async def dashboard(): OUTLINE_TOKEN{outline_tok} LOCAL_OUTLINE_URL{local_url} LOCAL_OUTLINE_TOKEN{local_tok} + LOCAL_OUTLINE_HOST{local_host} APP_VERSION{APP_VERSION}
""" @@ -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("

No compare results. Run Compare Instances first.

") + + 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""" + + + {key}
+ {title} + + + {ts} + + + + + +""" + + 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""" + + + {key} +
+
+
remote · {r_ts}
+
+ click View to load
+
+
+
local · {l_ts}
+
+ click View to load
+
+
+ + + + + + + +""" + + 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""" +
+

Remote only — {len(r_only)} documents

+

Exist on remote, missing on local. Copy to local or skip.

+ + {r_rows}
DocumentUpdatedAction
+
""" if r_only else "" + + l_section = f""" +
+

Local only — {len(l_only)} documents

+

Exist on local, missing on remote. Copy to remote or skip.

+ + {l_rows}
DocumentUpdatedAction
+
""" if l_only else "" + + c_section = f""" +
+

Conflicts — {len(conflicts)} documents

+

Exist on both with different content.

+ + {c_rows}
DocumentAction
+
""" if conflicts else "" + + ts = state.get("timestamp", "")[:19] + body = f""" +
+
+

Review Differences

+ compared {ts} +
+
+ {summary.get('in_sync',0)} in sync + {summary.get('remote_only',0)} remote only + {summary.get('local_only',0)} local only + {summary.get('conflicts',0)} conflicts +
+
+ {total} items to resolve + +
+
+ ← Back +
+
+{r_section} +{l_section} +{c_section} + +""" + + 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 # ---------------------------------------------------------------------------