feat: add two-instance compare review UI (Phase 2)
All checks were successful
Deploy / deploy (push) Successful in 12s
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user