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

@@ -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)