Files
outline-sync/outline_sync.py
domverse 3a5593f861
All checks were successful
Deploy / deploy (push) Successful in 12s
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 <noreply@anthropic.com>
2026-03-20 15:56:20 +01:00

993 lines
37 KiB
Python

#!/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: <id>
outline_collection_id: <id>
outline_parent_id: <id> ← omitted for root documents
outline_updated_at: <iso>
---
<document body from Outline API 'text' field>
"""
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()