feat: add compare command for two-instance diff (Phase 1)
All checks were successful
Deploy / deploy (push) Successful in 13s

Adds read-only `compare` command that fetches both the remote Outline
instance (via Tailscale) and a local instance (outline-web container),
matches documents by canonical path key, and reports in_sync / remote_only /
local_only / conflict status. Also adds PRD for the full two-instance sync
workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:35:47 +01:00
parent 6352fbb03f
commit 14af83a52a
2 changed files with 432 additions and 12 deletions

View File

@@ -178,15 +178,18 @@ class OutlineSync:
logger.error("Request failed on %s: %s", endpoint, exc)
return None
def health_check(self) -> bool:
def health_check(self, label: str = "") -> bool:
parsed = urlparse(self.base_url)
host = parsed.hostname or "outline"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
print(f"Checking API connectivity to {self.base_url} ...")
prefix = f"[{label}] " if label else ""
print(f"{prefix}Checking API connectivity to {self.base_url} ...")
indent = " "
# 1. DNS resolution
print(f" DNS resolve {host!r} ... ", end="", flush=True)
print(f"{indent}DNS resolve {host!r} ... ", end="", flush=True)
try:
ip = socket.gethostbyname(host)
print(f"✓ ({ip})")
@@ -195,7 +198,7 @@ class OutlineSync:
return False
# 2. TCP reachability
print(f" TCP connect {ip}:{port} ... ", end="", flush=True)
print(f"{indent}TCP connect {ip}:{port} ... ", end="", flush=True)
try:
with socket.create_connection((host, port), timeout=5):
print("")
@@ -204,7 +207,7 @@ class OutlineSync:
return False
# 3. API authentication
print(f" API auth ... ", end="", flush=True)
print(f"{indent}API auth ... ", end="", flush=True)
result = self._api("/api/auth.info")
if result and "data" in result:
user = result["data"].get("user", {})
@@ -666,6 +669,130 @@ class OutlineSync:
print(f"Done. {summary}.")
return errors == 0
# ── Compare ───────────────────────────────────────────────────────────────
def _collect_docs_keyed(
self,
nodes: List[Dict],
coll_name: str,
result: Dict[str, Dict],
parent_path: str = "",
) -> None:
"""Recursively populate result with {match_key: doc_meta} from a nav tree."""
for node in nodes:
title = node.get("title", "Untitled")
safe_title = sanitize_name(title).lower()
safe_coll = sanitize_name(coll_name).lower()
path = f"{parent_path}/{safe_title}" if parent_path else safe_title
match_key = f"{safe_coll}/{path}"
result[match_key] = {
"id": node["id"],
"title": title,
"updatedAt": node.get("updatedAt", ""),
}
for child in node.get("children", []):
self._collect_docs_keyed([child], coll_name, result, path)
def _fetch_all_docs_keyed(self) -> Dict[str, Dict]:
"""Return {match_key: doc_meta} for every document across all collections."""
out: Dict[str, Dict] = {}
for coll in self.get_collections():
tree = self.get_nav_tree(coll["id"])
self._collect_docs_keyed(tree, coll["name"], out)
return out
def cmd_compare(self, local_url: str, local_token: str) -> bool:
"""
Fetch both the remote and local Outline instances and print a diff report.
No writes to either instance.
Match key: collection_name/parent_chain/document_title (lowercased, sanitized)
Status per document:
in_sync — same path, content identical
remote_only — path exists on remote, not on local
local_only — path exists on local, not on remote
conflict — same path, content differs
"""
print("Fetching remote instance...")
if not self.health_check(label="remote"):
print("✗ Cannot reach remote Outline API — aborting.")
return False
remote_docs = self._fetch_all_docs_keyed()
print(f"{len(remote_docs)} documents\n")
print("Fetching local instance (outline-web)...")
local_client = OutlineSync(local_url, local_token, self.vault_dir)
if not local_client.health_check(label="local"):
print("✗ Cannot reach local Outline API — aborting.")
return False
local_docs = local_client._fetch_all_docs_keyed()
print(f"{len(local_docs)} documents\n")
all_keys = sorted(set(remote_docs) | set(local_docs))
results: Dict[str, List] = {
"in_sync": [],
"remote_only": [],
"local_only": [],
"conflict": [],
}
for key in all_keys:
r = remote_docs.get(key)
l = local_docs.get(key)
if r and l:
if r["updatedAt"] == l["updatedAt"]:
results["in_sync"].append(key)
else:
# Timestamps differ — compare actual content
r_full = self.get_document_info(r["id"])
l_full = local_client.get_document_info(l["id"])
r_text = (r_full or {}).get("text", "")
l_text = (l_full or {}).get("text", "")
if r_text.strip() == l_text.strip():
results["in_sync"].append(key)
else:
results["conflict"].append((key, r, l))
elif r:
results["remote_only"].append(key)
else:
results["local_only"].append(key)
in_sync = len(results["in_sync"])
remote_only = len(results["remote_only"])
local_only = len(results["local_only"])
conflicts = len(results["conflict"])
total = remote_only + local_only + conflicts
print("Diff summary:")
print(f" In sync: {in_sync:4d} documents")
print(f" Remote only: {remote_only:4d} documents")
print(f" Local only: {local_only:4d} documents")
print(f" Conflicts: {conflicts:4d} documents")
print( " " + "" * 30)
print(f" Total to resolve: {total}")
if results["remote_only"]:
print("\nRemote only:")
for key in results["remote_only"]:
print(f" + {key}")
if results["local_only"]:
print("\nLocal only:")
for key in results["local_only"]:
print(f" + {key}")
if results["conflict"]:
print("\nConflicts:")
for key, r, l in results["conflict"]:
r_ts = r["updatedAt"][:19] if r["updatedAt"] else "?"
l_ts = l["updatedAt"][:19] if l["updatedAt"] else "?"
print(f" ~ {key}")
print(f" remote: {r_ts} local: {l_ts}")
return True
# ── Commands ──────────────────────────────────────────────────────────────
def cmd_init(self) -> bool:
@@ -741,18 +868,23 @@ def load_settings(path: str) -> Dict:
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Outline ↔ Obsidian sync (Phase 1: init)",
description="Outline ↔ Obsidian sync",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Commands:\n"
" init Export Outline to vault and write git config files\n"
" init Export Outline to vault and write git config files\n"
" pull Fetch latest changes from remote Outline into vault\n"
" push Push local vault changes to remote Outline\n"
" compare Compare remote and local Outline instances (read-only)\n"
),
)
p.add_argument("command", choices=["init", "pull", "push"], help="Sync command")
p.add_argument("--vault", required=True, help="Path to vault directory")
p.add_argument("--settings", default="settings.json", help="Path to settings file")
p.add_argument("--url", help="Outline API URL (overrides settings.source.url)")
p.add_argument("--token", help="API token (overrides settings.source.token)")
p.add_argument("command", choices=["init", "pull", "push", "compare"], help="Sync command")
p.add_argument("--vault", required=True, help="Path to vault directory")
p.add_argument("--settings", default="settings.json", help="Path to settings file")
p.add_argument("--url", help="Remote Outline API URL (overrides settings.source.url)")
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(
"-v", "--verbose",
action="count",
@@ -772,6 +904,7 @@ def main() -> None:
settings = load_settings(args.settings)
source = settings.get("source", {})
local = settings.get("local", {})
url = args.url or source.get("url")
token = args.token or source.get("token")
@@ -795,6 +928,16 @@ def main() -> None:
ok = sync.cmd_pull()
elif args.command == "push":
ok = sync.cmd_push()
elif args.command == "compare":
local_url = args.local_url or local.get("url")
local_token = args.local_token or local.get("token")
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)
else:
ok = False
sys.exit(0 if ok else 1)