#!/usr/bin/env python3 """ Outline Sync — Phase 1: init Creates a local vault mirroring Outline wiki structure. Each document is written as a markdown file with YAML frontmatter containing the Outline document ID and metadata for future syncs. Git initialization is handled by sync.sh after this script exits. Usage (called by sync.sh, not directly): python3 outline_sync.py init --vault /vault --settings /work/settings.json """ import os import sys import re import json import socket import subprocess import time import logging import argparse from pathlib import Path from urllib.parse import urlparse from typing import Dict, List, Optional, Tuple import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # ── Logging ─────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.WARNING, format="%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("outline_sync") # ── Frontmatter helpers ─────────────────────────────────────────────────────── # Ordered fields written to every synced file FRONTMATTER_FIELDS = [ "outline_id", "outline_collection_id", "outline_parent_id", "outline_updated_at", ] GITIGNORE = """\ # Obsidian internals .obsidian/ # Sync config (contains API token) settings.json # Conflict sidecars (resolved manually) *.conflict.md # OS noise .DS_Store Thumbs.db """ GITATTRIBUTES = """\ # Normalize line endings for all markdown *.md text eol=lf # Sync log is append-only — never produce conflicts on it _sync_log.md merge=union """ def build_frontmatter(fields: Dict[str, str]) -> str: """Serialize an ordered dict of fields to a YAML frontmatter block.""" lines = ["---"] for key in FRONTMATTER_FIELDS: value = fields.get(key, "") if value: # omit empty values (e.g. outline_parent_id for root docs) lines.append(f"{key}: {value}") lines.append("---") return "\n".join(lines) + "\n" def parse_frontmatter(content: str) -> Tuple[Dict[str, str], str]: """ Parse a YAML frontmatter block from file content. Returns (frontmatter_dict, body_text). If no valid frontmatter block is found, returns ({}, original_content). """ if not content.startswith("---\n"): return {}, content end = content.find("\n---\n", 4) if end == -1: return {}, content fm_text = content[4:end] body = content[end + 5:] # skip past \n---\n fm: Dict[str, str] = {} for line in fm_text.splitlines(): if ": " in line: key, _, value = line.partition(": ") fm[key.strip()] = value.strip() return fm, body # ── Filename helpers ────────────────────────────────────────────────────────── _INVALID = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _SPACES = re.compile(r"\s+") def sanitize_name(name: str, max_len: int = 200) -> str: """Convert a document title to a safe filesystem name (no extension).""" name = _INVALID.sub("_", name) name = _SPACES.sub(" ", name).strip() return name[:max_len] if name else "Untitled" # ── OutlineSync ─────────────────────────────────────────────────────────────── class OutlineSync: 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) self.session = requests.Session() adapter = HTTPAdapter(max_retries=Retry( total=3, backoff_factor=1.0, status_forcelist=[429, 500, 502, 503, 504], )) self.session.mount("http://", adapter) self.session.mount("https://", adapter) self.headers = { "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} # ── API layer ───────────────────────────────────────────────────────────── def _api( self, endpoint: str, data: Optional[Dict] = None, method: str = "POST", ) -> Optional[Dict]: url = f"{self.base_url}{endpoint}" try: if method == "POST": r = self.session.post( url, headers=self.headers, json=data or {}, timeout=30 ) else: r = self.session.get(url, headers=self.headers, timeout=30) if r.status_code == 200: return r.json() logger.error("API %s on %s", r.status_code, endpoint) logger.debug("Response body: %s", r.text[:400]) return None except requests.RequestException as exc: logger.error("Request failed on %s: %s", endpoint, exc) return None 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) prefix = f"[{label}] " if label else "" print(f"{prefix}Checking API connectivity to {self.base_url} ...") indent = " " # 1. DNS resolution print(f"{indent}DNS resolve {host!r} ... ", end="", flush=True) try: ip = socket.gethostbyname(host) print(f"✓ ({ip})") except socket.gaierror as exc: print(f"✗ DNS failed: {exc}") return False # 2. TCP reachability print(f"{indent}TCP connect {ip}:{port} ... ", end="", flush=True) try: with socket.create_connection((host, port), timeout=5): print("✓") except (socket.timeout, ConnectionRefusedError, OSError) as exc: print(f"✗ {exc}") return False # 3. API authentication 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", {}) print(f"✓ (user: {user.get('name', 'unknown')})") return True print("✗ bad response") return False def get_collections(self) -> List[Dict]: result = self._api("/api/collections.list") if result and "data" in result: return result["data"] return [] def get_nav_tree(self, collection_id: str) -> List[Dict]: """Return the nested navigation tree for a collection.""" result = self._api("/api/collections.documents", {"id": collection_id}) if result and "data" in result: return result["data"] return [] def get_document_info(self, doc_id: str) -> Optional[Dict]: """Fetch full document content, using cache to avoid duplicate calls.""" if doc_id in self._doc_cache: return self._doc_cache[doc_id] result = self._api("/api/documents.info", {"id": doc_id}) if result and "data" in result: self._doc_cache[doc_id] = result["data"] return result["data"] return None # ── File writing ────────────────────────────────────────────────────────── def _write_doc_file( self, path: Path, doc_id: str, collection_id: str, parent_id: Optional[str], ) -> bool: """ Fetch document content and write it to path with YAML frontmatter. File format: --- outline_id: outline_collection_id: outline_parent_id: ← omitted for root documents outline_updated_at: --- """ full = self.get_document_info(doc_id) if not full: logger.warning("Could not fetch document %s — skipping", doc_id) self.stats["errors"] += 1 return False fm = { "outline_id": doc_id, "outline_collection_id": collection_id, "outline_parent_id": parent_id or "", "outline_updated_at": full.get("updatedAt", ""), } body = full.get("text", "") content = build_frontmatter(fm) + "\n" + body path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") self.stats["documents"] += 1 return True def _unique_path(self, directory: Path, name: str) -> Path: """Return a non-colliding .md path, appending _N suffix if needed.""" candidate = directory / f"{name}.md" counter = 1 while candidate.exists(): candidate = directory / f"{name}_{counter}.md" counter += 1 return candidate def _export_node( self, node: Dict, parent_dir: Path, collection_id: str, parent_doc_id: Optional[str], ) -> None: """ Recursively export one nav-tree node and all its children. Folder structure rule (from PRD §4.3): - Leaf document (no children) → parent_dir/Title.md - Document with children → parent_dir/Title/Title.md parent_dir/Title/Child.md ... This means the parent document and its children share the same folder. """ doc_id = node["id"] title = node.get("title", "Untitled") children = node.get("children", []) safe = sanitize_name(title) if children: # Create a named subdirectory; the document itself lives inside it doc_dir = parent_dir / safe doc_dir.mkdir(parents=True, exist_ok=True) doc_path = self._unique_path(doc_dir, safe) child_dir = doc_dir else: doc_path = self._unique_path(parent_dir, safe) child_dir = parent_dir # unused for leaf, but needed for recursion logger.info(" Writing %s", doc_path.relative_to(self.vault_dir)) ok = self._write_doc_file(doc_path, doc_id, collection_id, parent_doc_id) if ok: for child in children: self._export_node(child, child_dir, collection_id, doc_id) def export_collection(self, collection: Dict) -> int: """Export all documents for one collection. Returns count written.""" coll_id = collection["id"] coll_name = collection["name"] safe_name = sanitize_name(coll_name) coll_dir = self.vault_dir / safe_name coll_dir.mkdir(parents=True, exist_ok=True) print(f" {coll_name}/", end=" ", flush=True) nav_tree = self.get_nav_tree(coll_id) if not nav_tree: print("(empty)") self.stats["collections"] += 1 return 0 before = self.stats["documents"] for node in nav_tree: self._export_node(node, coll_dir, coll_id, None) count = self.stats["documents"] - before errors = self.stats["errors"] status = f"{count} documents" if errors: status += f" ⚠ {errors} errors" print(status) self.stats["collections"] += 1 return count # ── Config files ────────────────────────────────────────────────────────── def write_gitignore(self) -> None: (self.vault_dir / ".gitignore").write_text(GITIGNORE, encoding="utf-8") def write_gitattributes(self) -> None: (self.vault_dir / ".gitattributes").write_text(GITATTRIBUTES, encoding="utf-8") # ── Pull ────────────────────────────────────────────────────────────────── def _git(self, *args: str) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-C", str(self.vault_dir), *args], capture_output=True, text=True, ) def _collect_vault_ids(self) -> Dict[str, Path]: """Return {outline_id: path} for every tracked .md file in the vault.""" result: Dict[str, Path] = {} for md in self.vault_dir.rglob("*.md"): if ".git" in md.parts: continue try: fm, _ = parse_frontmatter(md.read_text(encoding="utf-8")) oid = fm.get("outline_id") if oid: result[oid] = md except OSError: pass return result def cmd_pull(self) -> bool: """ Fetch latest document content from Outline and update the vault. Runs entirely inside the outline-sync Docker container which has git + requests. /vault is mounted from the host. """ print("Fetching collections from Outline...") if not self.health_check(): print("✗ Cannot reach Outline API — aborting.") return False collections = self.get_collections() if not collections: print("No collections found.") return True # Collect all Outline documents all_docs: List[Dict] = [] for coll in collections: tree = self.get_nav_tree(coll["id"]) self._collect_tree_docs(tree, coll["id"], all_docs) # Map current vault files by outline_id vault_ids = self._collect_vault_ids() updated = 0 created = 0 errors = 0 # Switch to outline branch for writes self._git("stash", "--include-untracked", "-m", "webui: pre-pull stash") self._git("checkout", "outline") for doc_meta in all_docs: doc_id = doc_meta["id"] title = doc_meta.get("title", "Untitled") coll_id = doc_meta["collection_id"] parent_id = doc_meta.get("parent_id") outline_ts = doc_meta.get("updatedAt", "") full = self.get_document_info(doc_id) if not full: print(f"error: could not fetch {title}") errors += 1 continue outline_ts = full.get("updatedAt", "") if doc_id in vault_ids: path = vault_ids[doc_id] try: existing_fm, _ = parse_frontmatter(path.read_text(encoding="utf-8")) local_ts = existing_fm.get("outline_updated_at", "") except OSError: local_ts = "" if local_ts == outline_ts: continue # already up to date # Update existing file fm = { "outline_id": doc_id, "outline_collection_id": coll_id, "outline_parent_id": parent_id or "", "outline_updated_at": outline_ts, } content = build_frontmatter(fm) + "\n" + full.get("text", "") path.write_text(content, encoding="utf-8") rel = str(path.relative_to(self.vault_dir)) print(f"ok: {rel} updated") updated += 1 else: # New document — determine path from collection + parent hierarchy safe_coll = sanitize_name( next((c["name"] for c in collections if c["id"] == coll_id), coll_id) ) coll_dir = self.vault_dir / safe_coll if parent_id and parent_id in vault_ids: parent_path = vault_ids[parent_id] target_dir = parent_path.parent / parent_path.stem else: target_dir = coll_dir target_dir.mkdir(parents=True, exist_ok=True) path = self._unique_path(target_dir, sanitize_name(title)) fm = { "outline_id": doc_id, "outline_collection_id": coll_id, "outline_parent_id": parent_id or "", "outline_updated_at": outline_ts, } content = build_frontmatter(fm) + "\n" + full.get("text", "") path.write_text(content, encoding="utf-8") vault_ids[doc_id] = path # register so child docs resolve parent correctly rel = str(path.relative_to(self.vault_dir)) print(f"ok: {rel} created") created += 1 # Commit on outline branch if anything changed if updated + created > 0: self._git("add", "-A") ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) self._git("commit", "-m", f"sync: pull from Outline @ {ts}") # Back to main + merge self._git("checkout", "main") if updated + created > 0: self._git("merge", "outline", "--no-ff", "-m", f"merge: outline → main @ {ts}") self._git("stash", "pop") parts = [] if updated: parts.append(f"{updated} updated") if created: parts.append(f"{created} created") if errors: parts.append(f"{errors} errors") summary = ", ".join(parts) if parts else "0 changes" print(f"Done. {summary}.") return errors == 0 def _collect_tree_docs( self, nodes: List[Dict], collection_id: str, out: List[Dict], parent_id: Optional[str] = None, ) -> None: for node in nodes: doc = { "id": node["id"], "title": node.get("title", "Untitled"), "collection_id": collection_id, "parent_id": parent_id, "updatedAt": node.get("updatedAt", ""), } out.append(doc) for child in node.get("children", []): self._collect_tree_docs([child], collection_id, out, node["id"]) # ── Push ────────────────────────────────────────────────────────────────── def cmd_push(self) -> bool: """ Push local changes (main vs outline) to Outline. For each file changed on main relative to outline: - Has outline_id → call documents.update - No outline_id → call documents.create, write back frontmatter Runs entirely inside the outline-sync Docker container. """ print("Checking local changes...") if not self.health_check(): print("✗ Cannot reach Outline API — aborting.") return False # Diff main vs outline r = self._git("diff", "--name-status", "outline", "main", "--", "*.md") if r.returncode != 0: print(f"error: git diff failed: {r.stderr.strip()}") return False changed_files: List[Tuple[str, str]] = [] # (status, path) for line in r.stdout.splitlines(): parts = line.split("\t", 1) if len(parts) == 2: status, path = parts changed_files.append((status.strip(), path.strip())) if not changed_files: print("Done. 0 changes.") return True collections = self.get_collections() coll_by_name = {sanitize_name(c["name"]): c["id"] for c in collections} updated = 0 created = 0 errors = 0 for status, rel_path in changed_files: if rel_path.startswith("_"): continue # skip _sync_log.md etc. full_path = self.vault_dir / rel_path if not full_path.exists(): continue print(f"processing: {rel_path}") try: content = full_path.read_text(encoding="utf-8") except OSError as exc: print(f"error: {rel_path}: {exc}") errors += 1 continue fm, body = parse_frontmatter(content) doc_id = fm.get("outline_id") title = full_path.stem if doc_id: # Update existing document result = self._api("/api/documents.update", { "id": doc_id, "text": body, }) if result and "data" in result: new_ts = result["data"].get("updatedAt", "") fm["outline_updated_at"] = new_ts full_path.write_text(build_frontmatter(fm) + "\n" + body, encoding="utf-8") print(f"ok: {rel_path} updated") updated += 1 else: print(f"error: {rel_path} update failed") errors += 1 else: # Create new document path_parts = Path(rel_path).parts coll_name = sanitize_name(path_parts[0]) if len(path_parts) > 1 else "" coll_id = coll_by_name.get(coll_name) if not coll_id: # Create the collection r_coll = self._api("/api/collections.create", { "name": path_parts[0] if len(path_parts) > 1 else "Imported", "private": False, }) if r_coll and "data" in r_coll: coll_id = r_coll["data"]["id"] coll_by_name[coll_name] = coll_id print(f"ok: collection '{path_parts[0]}' created (id: {coll_id})") else: print(f"error: could not create collection for {rel_path}") errors += 1 continue result = self._api("/api/documents.create", { "title": title, "text": body, "collectionId": coll_id, "publish": True, }) if result and "data" in result: new_id = result["data"]["id"] new_ts = result["data"].get("updatedAt", "") new_coll_id = result["data"].get("collectionId", coll_id) fm = { "outline_id": new_id, "outline_collection_id": new_coll_id, "outline_parent_id": "", "outline_updated_at": new_ts, } full_path.write_text(build_frontmatter(fm) + "\n" + body, encoding="utf-8") print(f"ok: {rel_path} created (id: {new_id})") created += 1 else: print(f"error: {rel_path} create failed") errors += 1 # Commit frontmatter writebacks + advance outline branch r_diff = self._git("diff", "--quiet") if r_diff.returncode != 0: self._git("add", "-A") ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) self._git("commit", "-m", f"sync: push to Outline @ {ts}") self._git("checkout", "outline") self._git("merge", "main", "--ff-only") self._git("checkout", "main") parts = [] if updated: parts.append(f"{updated} updated") if created: parts.append(f"{created} created") if errors: parts.append(f"{errors} errors") summary = ", ".join(parts) if parts else "0 changes" 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, local_host: 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, host_header=local_host) 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}") # 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 ────────────────────────────────────────────────────────────── def cmd_init(self) -> bool: """ Initialize the vault from current Outline state. Writes all documents as markdown files with YAML frontmatter. Also writes .gitignore and .gitattributes. Git initialization (branches, first commit) is done by sync.sh. """ print("════════════════════════════════════════════════════════════") print(" OUTLINE SYNC — init (file export)") print("════════════════════════════════════════════════════════════") print() print(f"Vault: {self.vault_dir}") print(f"Source: {self.base_url}") print() # Guard: refuse if .git already exists if (self.vault_dir / ".git").exists(): print(f"✗ Vault is already a git repo: {self.vault_dir}") print(" Remove the directory first or choose a different path.") return False self.vault_dir.mkdir(parents=True, exist_ok=True) if not self.health_check(): print("✗ Cannot reach Outline API — aborting.") return False print() collections = self.get_collections() if not collections: print("✗ No collections found in Outline.") return False print(f"Exporting {len(collections)} collection(s)...") for coll in collections: self.export_collection(coll) self.write_gitignore() self.write_gitattributes() print() print("════════════════════════════════════════════════════════════") c = self.stats["collections"] d = self.stats["documents"] e = self.stats["errors"] print(f" {c} collection(s), {d} document(s) exported") if e: print(f" {e} error(s) — see warnings above") print() print(" Git setup will be completed by sync.sh.") print("════════════════════════════════════════════════════════════") return e == 0 # ── Settings + CLI ──────────────────────────────────────────────────────────── def load_settings(path: str) -> Dict: try: with open(path) as f: return json.load(f) except FileNotFoundError: logger.error("Settings file not found: %s", path) sys.exit(1) except json.JSONDecodeError as exc: logger.error("Invalid JSON in %s: %s", path, exc) sys.exit(1) def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Outline ↔ Obsidian sync", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Commands:\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", "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("--local-host", help="Host header for local instance (overrides settings.local.host)") p.add_argument( "-v", "--verbose", action="count", default=0, help="Increase verbosity (-v for INFO, -vv for DEBUG)", ) return p.parse_args() def main() -> None: args = parse_args() if args.verbose >= 2: logger.setLevel(logging.DEBUG) elif args.verbose == 1: logger.setLevel(logging.INFO) 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") if not url or not token: logger.error( "Missing API URL or token — set source.url and source.token " "in settings.json, or pass --url / --token." ) sys.exit(1) sync = OutlineSync( base_url = url, api_token = token, vault_dir = Path(args.vault), ) if args.command == "init": ok = sync.cmd_init() elif args.command == "pull": 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") 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, local_host) else: ok = False sys.exit(0 if ok else 1) if __name__ == "__main__": main()