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>
8.8 KiB
PRD: Two-Instance Outline Sync
Overview
Extend outline-sync to support bidirectional comparison and synchronization between two live Outline instances: a remote instance (accessed via Tailscale) and a local instance (accessed directly). The goal is to bring both instances to identical content state after resolving all differences interactively.
Current State
The current tool performs a one-way pull from the remote Outline instance into a local Git-backed vault (Obsidian-compatible markdown). This vault acts as a local representation of the remote instance's content.
Goal
Enable a four-step workflow:
- Pull from remote instance into local vault (existing)
- Fetch from local Outline instance and detect differences vs. vault
- Review differences interactively (left = remote, right = local)
- Resolve all differences and push chosen versions to both instances
End state: both Outline instances contain identical content.
Instances
| Side | Label | URL | Token |
|---|---|---|---|
| Left (Remote) | remote |
http://outline:3000 (Tailscale) |
From settings.json |
| Right (Local) | local |
http://outline-web:3000 (Docker container) |
ol_api_PBU1EX6aRlUVkXzD995oGpMOmYOXpdXEWeEQll |
Document Matching Strategy
Since the two Outline instances assign independent document IDs, matching must be by content path — the canonical path is Collection / Parent Chain / Document Title derived from the navigation tree.
Match key: collection_name / parent_title / ... / document_title (case-insensitive, normalized)
Match outcomes per document:
| Outcome | Condition |
|---|---|
in_sync |
Match found, content and updatedAt identical |
remote_only |
Path exists on remote, not found on local |
local_only |
Path exists on local, not found on remote |
conflict |
Path exists on both, content differs |
Step 2 — Fetch & Diff
Process
- Pull from remote into vault (existing Step 1).
- Instantiate a second
OutlineSyncclient for the local instance. - Fetch all collections + document trees from local instance.
- For each document on either side, compute the match key.
- Build a diff report: a structured list of
DiffEntryobjects.
DiffEntry Model
@dataclass
class DiffEntry:
match_key: str # Canonical path used for matching
status: Literal["in_sync", "remote_only", "local_only", "conflict"]
remote_doc: DocMeta | None # None if remote_only is False
local_doc: DocMeta | None # None if local_only is False
@dataclass
class DocMeta:
instance: str # "remote" | "local"
doc_id: str
title: str
collection_id: str
collection_name: str
parent_id: str | None
updated_at: str # ISO timestamp
content: str # Full markdown body
CLI Output (Step 2)
Fetching remote... ✓ (47 documents in 6 collections)
Fetching local... ✓ (39 documents in 5 collections)
Diff summary:
In sync: 35 documents
Remote only: 8 documents
Local only: 4 documents
Conflicts: 8 documents
─────────────────────────────
Total to resolve: 20
Step 3 — Interactive Conflict Resolution
Interface Options
Two interfaces will be supported:
A. Terminal (CLI) — Primary
An interactive terminal UI (using rich or plain TTY prompts) presents each non-in_sync document one at a time.
Display per item:
[8/20] CONFLICT — Projekte / outline-sync / Architecture
LEFT (remote) updated 2026-03-17T09:12:00Z
RIGHT (local) updated 2026-03-15T14:30:00Z
--- remote
+++ local
@@ -3,7 +3,9 @@
## Overview
-The sync tool uses Git as a local cache.
+The sync tool uses Git as a local cache and Tailscale for connectivity.
+
+Added section on networking.
Choose action:
[r] Keep remote [l] Keep local [s] Skip [d] Full diff [q] Quit
For remote_only:
[3/20] REMOTE ONLY — Bewerbungen / Company XYZ / Cover Letter
Document exists on remote, not on local.
Choose: [r] Copy to local [s] Skip [q] Quit
For local_only:
[5/20] LOCAL ONLY — Projekte / New Project / Notes
Document exists on local, not on remote.
Choose: [l] Copy to remote [s] Skip [q] Quit
B. Web UI — Optional (Phase 2)
A web page with a split-panel diff view (similar to a code review UI) with Accept Left / Accept Right / Edit buttons. Built on the existing FastAPI + HTMX stack.
Step 4 — Resolution & Write-Back
After the user has resolved all items, the tool executes the chosen actions:
| User Choice | Action |
|---|---|
| Keep remote | Push remote content to local Outline via API (documents.create or documents.update) |
| Keep local | Push local content to remote Outline via API |
| Copy to local | Create document on local instance with remote content |
| Copy to remote | Create document on remote instance with local content |
| Skip | No action for this document |
Write-Back Rules
- Collection must exist on target before creating a document. Auto-create collection if missing.
- Parent document must exist on target before creating a child. Create parents recursively (depth-first).
- Title and markdown body are copied as-is. Frontmatter (
outline_idetc.) is not transferred — target instance assigns its own IDs. - Updated timestamps are NOT forcibly set — the target instance sets
updatedAton create/update.
Final Report
Applying resolutions...
✓ Copied to local: 8 documents
✓ Copied to remote: 4 documents
✓ Pushed to local: 3 documents (conflict: chose remote)
✓ Pushed to remote: 5 documents (conflict: chose local)
- Skipped: 0 documents
Both instances now have 47 documents in 6 collections.
Configuration
Add a local block to settings.json:
{
"source": {
"url": "http://outline:3000",
"token": "ol_api_..."
},
"local": {
"url": "http://outline-web:3000",
"token": "ol_api_PBU1EX6aRlUVkXzD995oGpMOmYOXpdXEWeEQll"
}
}
CLI flags override settings:
./sync.sh compare --local-url http://localhost:3000 --local-token ol_api_...
./sync.sh compare --auto-resolve newer # Auto-pick newer timestamp, no prompts
New CLI Commands
| Command | Description |
|---|---|
compare |
Fetch both instances and print diff report (no writes) |
merge |
Interactive resolution workflow (Steps 2–4) |
merge --auto-resolve newer |
Non-interactive: always pick newer updatedAt |
merge --auto-resolve remote |
Non-interactive: always keep remote version |
merge --auto-resolve local |
Non-interactive: always keep local version |
Non-Goals
- Attachment/image sync — text content only (consistent with existing NG3)
- Real-time continuous sync — this is a manual, on-demand operation
- Three-way merge — no character-level content merging; user picks one side
- Deletion propagation — documents deleted on one side are not auto-deleted on the other; they appear as
remote_onlyorlocal_only - Comment/reaction sync — document body only
- Preserving Outline metadata (pinned, starred, views) — content only
Implementation Phases
Phase 1 — Compare (Step 2 only)
- Add
localconfig block - Implement
LocalOutlineClient(reuseOutlineSyncAPI methods, different credentials) - Build
DiffReport: fetch both instances, match by path, classify entries comparecommand: print diff summary to terminal- No writes to either instance
Phase 2 — Interactive CLI Merge (Steps 3–4)
mergecommand with TTY prompts--auto-resolveflag for non-interactive use- Write-back:
documents.create/documents.updateon target - Recursive parent creation
- Final report
Phase 3 — Web UI Diff View (Optional)
- Split-panel view in FastAPI + HTMX
- Accept Left / Accept Right buttons per document
- Progress indicator during write-back
Success Criteria
comparecorrectly identifies all differences between two live Outline instancesmergeallows user to resolve every difference and both instances end up with identical document trees- No data is lost — skipped documents remain on their original instance
- The tool is re-runnable: running
compareafter a successfulmergereports 0 differences
Decisions
- Rename detection: Treated as
remote_only+local_only(delete + create). No fuzzy matching. - Collection renames: Same — treated as entirely separate collections with all-new documents.
- Local instance: Docker container
outline-web, accessible athttp://outline-web:3000. - Initial scope: Phase 1 only (
comparecommand, read-only). Phases 2–3 follow after matching logic is validated.