docspan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. docspan-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,332 @@
1
+ """Sync orchestration logic — decoupled from the CLI layer.
2
+
3
+ Each public function handles one push or pull scenario and returns a typed
4
+ outcome that the CLI can render without knowing any sync logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import TYPE_CHECKING, Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ from docspan.backends.base import Backend, PullResult, PushResult
19
+ from docspan.core.merge import three_way_merge
20
+ from docspan.core.paths import BASE_FILE_SUFFIX, BASE_STORE_DIR, ORIG_SUFFIX, STATE_FILENAME
21
+ from docspan.core.state import MappingState, SyncState, sha256_of_content
22
+
23
+ if TYPE_CHECKING:
24
+ from docspan.config import Mapping
25
+
26
+
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+ # Path helpers
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ def get_state_path(config_path: Optional[str]) -> str:
32
+ state_dir = _state_dir(config_path)
33
+ return os.path.join(state_dir, STATE_FILENAME)
34
+
35
+
36
+ def get_state_dir(config_path: Optional[str]) -> str:
37
+ return _state_dir(config_path)
38
+
39
+
40
+ def _state_dir(config_path: Optional[str]) -> str:
41
+ if config_path is not None:
42
+ return os.path.dirname(os.path.abspath(config_path))
43
+ return os.getcwd()
44
+
45
+
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+ # Content-addressed base store
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ def get_base_content(state_dir: str, base_hash: str) -> str:
51
+ """Read the merge base for a file from the content-addressed store."""
52
+ base_path = os.path.join(state_dir, BASE_STORE_DIR, f"{base_hash}{BASE_FILE_SUFFIX}")
53
+ if not os.path.exists(base_path):
54
+ return ""
55
+ with open(base_path, encoding="utf-8") as fh:
56
+ return fh.read()
57
+
58
+
59
+ def save_base_content(state_dir: str, content: str) -> str:
60
+ """Write content to the content-addressed base store. Returns the sha256 hex digest."""
61
+ sha = sha256_of_content(content)
62
+ base_dir = os.path.join(state_dir, BASE_STORE_DIR)
63
+ os.makedirs(base_dir, exist_ok=True)
64
+ base_path = os.path.join(base_dir, f"{sha}{BASE_FILE_SUFFIX}")
65
+ if not os.path.exists(base_path):
66
+ with open(base_path, "w", encoding="utf-8") as fh:
67
+ fh.write(content)
68
+ return sha
69
+
70
+
71
+ # ─────────────────────────────────────────────────────────────────────────────
72
+ # Outcome types
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ @dataclass
76
+ class PushOutcome:
77
+ local_path: str
78
+ result: PushResult
79
+ state_saved: bool = False
80
+
81
+
82
+ @dataclass
83
+ class PullOutcome:
84
+ local_path: str
85
+ # "first-sync" | "fast-forward" | "merged" | "up-to-date" | "local-only" | "error"
86
+ action: str
87
+ result: Optional[PullResult] = None
88
+ has_conflicts: bool = False
89
+ conflict_count: int = 0
90
+
91
+
92
+ # ─────────────────────────────────────────────────────────────────────────────
93
+ # State recording
94
+ # ─────────────────────────────────────────────────────────────────────────────
95
+
96
+ def record_state(
97
+ state: SyncState,
98
+ state_path: str,
99
+ state_dir: str,
100
+ local_path: str,
101
+ doc_id: str,
102
+ backend_name: str,
103
+ content: str,
104
+ remote_version: str,
105
+ ) -> bool:
106
+ """Persist sync state after a successful operation. Returns True on success."""
107
+ try:
108
+ local_hash = sha256_of_content(content)
109
+ base_hash = save_base_content(state_dir, content)
110
+ state.update(
111
+ local_path,
112
+ MappingState(
113
+ doc_id=doc_id,
114
+ backend=backend_name,
115
+ last_synced_at=datetime.now(timezone.utc).isoformat(),
116
+ base_hash=base_hash,
117
+ remote_version=remote_version,
118
+ local_hash=local_hash,
119
+ ),
120
+ )
121
+ state.save(state_path)
122
+ return True
123
+ except Exception:
124
+ logger.warning("Failed to save sync state for %s", local_path, exc_info=True)
125
+ return False
126
+
127
+
128
+ def _record_state(
129
+ state: SyncState,
130
+ state_path: str,
131
+ state_dir: str,
132
+ local_path: str,
133
+ mapping: "Mapping",
134
+ content: str,
135
+ remote_version: str,
136
+ ) -> bool:
137
+ return record_state(
138
+ state, state_path, state_dir, local_path,
139
+ mapping.remote_id, mapping.backend, content, remote_version,
140
+ )
141
+
142
+
143
+ # ─────────────────────────────────────────────────────────────────────────────
144
+ # Push orchestration
145
+ # ─────────────────────────────────────────────────────────────────────────────
146
+
147
+ def orchestrate_push(
148
+ mapping: "Mapping",
149
+ backend: Backend,
150
+ state: SyncState,
151
+ state_dir: str,
152
+ state_path: str,
153
+ ) -> PushOutcome:
154
+ result = backend.push(mapping.local, mapping.remote_id)
155
+ outcome = PushOutcome(local_path=mapping.local, result=result)
156
+
157
+ if result.status == "ok" and os.path.exists(mapping.local):
158
+ try:
159
+ remote_version = backend.get_remote_version(mapping.remote_id)
160
+ except Exception:
161
+ logger.warning(
162
+ "Could not retrieve remote version after push for %s; "
163
+ "recording empty version — next pull will re-sync",
164
+ mapping.remote_id,
165
+ exc_info=True,
166
+ )
167
+ remote_version = ""
168
+ with open(mapping.local, encoding="utf-8") as fh:
169
+ content = fh.read()
170
+ outcome.state_saved = _record_state(
171
+ state, state_path, state_dir, mapping.local, mapping, content, remote_version
172
+ )
173
+
174
+ return outcome
175
+
176
+
177
+ # ─────────────────────────────────────────────────────────────────────────────
178
+ # Pull orchestration
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+
181
+ def orchestrate_pull(
182
+ mapping: "Mapping",
183
+ backend: Backend,
184
+ state: SyncState,
185
+ state_dir: str,
186
+ state_path: str,
187
+ ) -> PullOutcome:
188
+ entry = state.get(mapping.local)
189
+
190
+ local_exists = os.path.exists(mapping.local)
191
+ if local_exists:
192
+ with open(mapping.local, encoding="utf-8") as fh:
193
+ local_content = fh.read()
194
+ current_local_hash = sha256_of_content(local_content)
195
+ else:
196
+ local_content = ""
197
+ current_local_hash = ""
198
+
199
+ try:
200
+ remote_version = backend.get_remote_version(mapping.remote_id)
201
+ except Exception as exc:
202
+ return PullOutcome(
203
+ local_path=mapping.local,
204
+ action="error",
205
+ result=PullResult(
206
+ status="error",
207
+ doc_id=mapping.remote_id,
208
+ local_path=mapping.local,
209
+ message=str(exc),
210
+ ),
211
+ )
212
+
213
+ if entry is None:
214
+ return _first_sync_pull(mapping, backend, state, state_dir, state_path, remote_version)
215
+
216
+ remote_changed = remote_version != entry.remote_version
217
+ local_changed = local_exists and current_local_hash != entry.local_hash
218
+
219
+ if not remote_changed and not local_changed:
220
+ return PullOutcome(local_path=mapping.local, action="up-to-date")
221
+
222
+ if remote_changed and not local_changed:
223
+ return _fast_forward_pull(
224
+ mapping, backend, state, state_dir, state_path, remote_version
225
+ )
226
+
227
+ if local_changed and not remote_changed:
228
+ return PullOutcome(local_path=mapping.local, action="local-only")
229
+
230
+ # Both sides changed — three-way merge
231
+ return _merge_pull(
232
+ mapping, backend, state, state_dir, state_path,
233
+ local_content, remote_version, entry.base_hash,
234
+ )
235
+
236
+
237
+ def _first_sync_pull(
238
+ mapping: "Mapping",
239
+ backend: Backend,
240
+ state: SyncState,
241
+ state_dir: str,
242
+ state_path: str,
243
+ remote_version: str,
244
+ ) -> PullOutcome:
245
+ result = backend.pull(mapping.remote_id, mapping.local)
246
+ outcome = PullOutcome(local_path=mapping.local, action="first-sync", result=result)
247
+ if result.status == "ok" and os.path.exists(mapping.local):
248
+ with open(mapping.local, encoding="utf-8") as fh:
249
+ new_content = fh.read()
250
+ _record_state(
251
+ state, state_path, state_dir, mapping.local, mapping,
252
+ new_content, remote_version or "",
253
+ )
254
+ return outcome
255
+
256
+
257
+ def _fast_forward_pull(
258
+ mapping: "Mapping",
259
+ backend: Backend,
260
+ state: SyncState,
261
+ state_dir: str,
262
+ state_path: str,
263
+ remote_version: str,
264
+ ) -> PullOutcome:
265
+ result = backend.pull(mapping.remote_id, mapping.local)
266
+ outcome = PullOutcome(local_path=mapping.local, action="fast-forward", result=result)
267
+ if result.status == "ok" and os.path.exists(mapping.local):
268
+ with open(mapping.local, encoding="utf-8") as fh:
269
+ new_content = fh.read()
270
+ _record_state(
271
+ state, state_path, state_dir, mapping.local, mapping,
272
+ new_content, remote_version,
273
+ )
274
+ return outcome
275
+
276
+
277
+ def _merge_pull(
278
+ mapping: "Mapping",
279
+ backend: Backend,
280
+ state: SyncState,
281
+ state_dir: str,
282
+ state_path: str,
283
+ local_content: str,
284
+ remote_version: str,
285
+ base_hash: str,
286
+ ) -> PullOutcome:
287
+ orig_path = mapping.local + ORIG_SUFFIX
288
+ with open(orig_path, "w", encoding="utf-8") as fh:
289
+ fh.write(local_content)
290
+
291
+ try:
292
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as tmp:
293
+ tmp_path = tmp.name
294
+ tmp_result = backend.pull(mapping.remote_id, tmp_path)
295
+ if tmp_result.status == "ok":
296
+ with open(tmp_path, encoding="utf-8") as fh:
297
+ theirs_content = fh.read()
298
+ os.unlink(tmp_path)
299
+ else:
300
+ os.unlink(tmp_path)
301
+ return PullOutcome(
302
+ local_path=mapping.local, action="error", result=tmp_result
303
+ )
304
+ except Exception as exc:
305
+ return PullOutcome(
306
+ local_path=mapping.local,
307
+ action="error",
308
+ result=PullResult(
309
+ status="error",
310
+ doc_id=mapping.remote_id,
311
+ local_path=mapping.local,
312
+ message=str(exc),
313
+ ),
314
+ )
315
+
316
+ base_content = get_base_content(state_dir, base_hash)
317
+ merge_result = three_way_merge(base_content, theirs_content, local_content)
318
+
319
+ with open(mapping.local, "w", encoding="utf-8") as fh:
320
+ fh.write(merge_result.merged)
321
+
322
+ _record_state(
323
+ state, state_path, state_dir, mapping.local, mapping,
324
+ merge_result.merged, remote_version,
325
+ )
326
+
327
+ return PullOutcome(
328
+ local_path=mapping.local,
329
+ action="merged",
330
+ has_conflicts=merge_result.has_conflicts,
331
+ conflict_count=merge_result.conflict_count,
332
+ )
docspan/core/paths.py ADDED
@@ -0,0 +1,8 @@
1
+ """Centralised path constants for docspan's local state files."""
2
+
3
+ STATE_FILENAME = ".markgate-state.json"
4
+ BASE_STORE_DIR = ".markgate-base"
5
+ BASE_FILE_SUFFIX = ".base"
6
+ ORIG_SUFFIX = ".orig"
7
+ COMMENTS_SUFFIX = ".comments.md"
8
+ GOOGLE_TOKEN_PATH = ".markgate/google_token.json"
docspan/core/state.py ADDED
@@ -0,0 +1,53 @@
1
+ """Sync state — tracks last-synced hashes and remote versions per local file."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass
12
+ class MappingState:
13
+ doc_id: str
14
+ backend: str
15
+ last_synced_at: str
16
+ base_hash: str
17
+ remote_version: str
18
+ local_hash: str
19
+
20
+
21
+ @dataclass
22
+ class SyncState:
23
+ mappings: dict[str, MappingState] = field(default_factory=dict)
24
+
25
+ @classmethod
26
+ def load(cls, path: str) -> "SyncState":
27
+ if not os.path.exists(path):
28
+ return cls()
29
+ with open(path) as f:
30
+ data = json.load(f)
31
+ mappings = {k: MappingState(**v) for k, v in data.get("mappings", {}).items()}
32
+ return cls(mappings=mappings)
33
+
34
+ def save(self, path: str) -> None:
35
+ tmp = path + ".tmp"
36
+ with open(tmp, "w") as f:
37
+ json.dump({"mappings": {k: asdict(v) for k, v in self.mappings.items()}}, f, indent=2)
38
+ os.rename(tmp, path)
39
+
40
+ def get(self, local_path: str) -> Optional[MappingState]:
41
+ return self.mappings.get(local_path)
42
+
43
+ def update(self, local_path: str, mapping: MappingState) -> None:
44
+ self.mappings[local_path] = mapping
45
+
46
+
47
+ def sha256_of_file(path: str) -> str:
48
+ with open(path, "rb") as fh:
49
+ return hashlib.sha256(fh.read()).hexdigest()
50
+
51
+
52
+ def sha256_of_content(content: str) -> str:
53
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
@@ -0,0 +1,273 @@
1
+ Metadata-Version: 2.4
2
+ Name: docspan
3
+ Version: 0.1.0
4
+ Summary: Push and pull markdown to Google Docs and Confluence from a single CLI
5
+ Project-URL: Homepage, https://github.com/tstapler/docspan
6
+ Project-URL: Repository, https://github.com/tstapler/docspan
7
+ Project-URL: Issues, https://github.com/tstapler/docspan/issues
8
+ Project-URL: Changelog, https://github.com/tstapler/docspan/releases
9
+ Author-email: Tyler Stapler <tystapler@gmail.com>
10
+ License: MIT
11
+ Keywords: cli,confluence,docs-as-code,google-docs,markdown,sync
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Documentation
22
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: google-api-python-client>=2.108.0
25
+ Requires-Dist: google-auth-httplib2>=0.1.1
26
+ Requires-Dist: google-auth-oauthlib>=1.1.0
27
+ Requires-Dist: google-auth>=2.23.0
28
+ Requires-Dist: httpx>=0.24.0
29
+ Requires-Dist: markdownify>=0.11.6
30
+ Requires-Dist: merge3>=0.0.16
31
+ Requires-Dist: mistune>=3.0
32
+ Requires-Dist: pydantic>=2.0.0
33
+ Requires-Dist: python-dateutil>=2.8.2
34
+ Requires-Dist: pyyaml>=6.0
35
+ Requires-Dist: requests>=2.25.0
36
+ Requires-Dist: rich>=13.0.0
37
+ Requires-Dist: typer>=0.9.0
38
+ Provides-Extra: dev
39
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
40
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
41
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
42
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
43
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
44
+ Requires-Dist: types-requests>=2.25.0; extra == 'dev'
45
+ Provides-Extra: docs
46
+ Requires-Dist: mkdocs-material<10.0,>=9.5.0; extra == 'docs'
47
+ Requires-Dist: mkdocs<2.0,>=1.5.0; extra == 'docs'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # docspan
51
+
52
+ [![PyPI](https://img.shields.io/pypi/v/docspan)](https://pypi.org/project/docspan/)
53
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
54
+
55
+ Push and pull markdown to Google Docs and Confluence from a single CLI. docspan provides bidirectional sync with three-way merge conflict detection, structural diff push that preserves comments on unchanged paragraphs, and a simple YAML-based configuration file.
56
+
57
+ The config file is named `markgate.yaml` — this name is preserved for backward compatibility and will be renamed in v0.2.0.
58
+
59
+ ---
60
+
61
+ ## Supported Backends
62
+
63
+ | Backend | Push | Pull |
64
+ |---|---|---|
65
+ | Google Docs | yes | yes |
66
+ | Confluence | yes | yes |
67
+
68
+ ---
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ pip install docspan
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Quick start
79
+
80
+ **1. Create `markgate.yaml`:**
81
+
82
+ ```yaml
83
+ backends:
84
+ google_docs:
85
+ credentials_path: /path/to/service-account.json
86
+
87
+ mappings:
88
+ - local: docs/design-doc.md
89
+ backend: google_docs
90
+ remote_id: YOUR_GOOGLE_DOC_ID
91
+ direction: both
92
+ ```
93
+
94
+ **2. Set up authentication:**
95
+
96
+ ```bash
97
+ docspan auth setup google_docs
98
+ # or
99
+ docspan auth setup confluence
100
+ ```
101
+
102
+ **3. Push and pull:**
103
+
104
+ ```bash
105
+ docspan push # push all mappings
106
+ docspan pull # pull all mappings
107
+ docspan status # show mapping table
108
+ ```
109
+
110
+ **4. Resolve conflicts (if any):**
111
+
112
+ ```bash
113
+ docspan conflicts list
114
+ docspan conflicts resolve docs/design-doc.md --accept remote
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Configuration (`markgate.yaml`)
120
+
121
+ ```yaml
122
+ backends:
123
+ google_docs:
124
+ credentials_path: /path/to/service-account.json # or use env ACCOUNT_A_CREDENTIALS_PATH
125
+ confluence:
126
+ base_url: https://yourorg.atlassian.net
127
+ username: you@example.com
128
+ api_token: your-api-token # or env CONFLUENCE_API_TOKEN
129
+
130
+ mappings:
131
+ - local: docs/notes.md
132
+ backend: google_docs
133
+ remote_id: YOUR_GOOGLE_DOC_ID
134
+ direction: both # push | pull | both
135
+ - local: docs/page.md
136
+ backend: confluence
137
+ remote_id: YOUR_CONFLUENCE_PAGE_ID
138
+ direction: both
139
+ ```
140
+
141
+ **Note**: `markgate.yaml` is gitignored by default because it may contain API tokens. Commit a `markgate.yaml.example` template alongside it.
142
+
143
+ ---
144
+
145
+ ## Command Reference
146
+
147
+ ### `docspan push`
148
+
149
+ ```
150
+ docspan push [FILES]... [--dry-run] [--config PATH]
151
+ ```
152
+
153
+ Push local markdown files to remote docs. Skips mappings with `direction = "pull"`. Accepts an optional list of local file paths to restrict which mappings are pushed.
154
+
155
+ ### `docspan pull`
156
+
157
+ ```
158
+ docspan pull [FILES]... [--dry-run] [--config PATH]
159
+ ```
160
+
161
+ Pull remote documents into local markdown files with three-way merge. Writes conflict markers to the file if automatic merge fails.
162
+
163
+ ### `docspan status`
164
+
165
+ ```
166
+ docspan status [--config PATH]
167
+ ```
168
+
169
+ Display all configured mappings in a table showing local file, backend, remote ID, and direction.
170
+
171
+ ### `docspan auth setup`
172
+
173
+ ```
174
+ docspan auth setup BACKEND [--config PATH]
175
+ ```
176
+
177
+ Interactive authentication setup. `BACKEND` is one of `google_docs` or `confluence`.
178
+
179
+ For Google Docs, prints step-by-step service account setup instructions. For Confluence, prompts for base URL, username, and API token, then prints a YAML snippet to add to `markgate.yaml`.
180
+
181
+ ### `docspan conflicts list`
182
+
183
+ ```
184
+ docspan conflicts list [--config PATH]
185
+ ```
186
+
187
+ Scan all tracked files for unresolved merge conflict markers (`<<<<<<< `). Prints a table of conflicted files and conflict block counts.
188
+
189
+ ### `docspan conflicts resolve`
190
+
191
+ ```
192
+ docspan conflicts resolve FILE --accept remote|local|merged [--config PATH]
193
+ ```
194
+
195
+ Resolve a merge conflict in a tracked file.
196
+
197
+ | Strategy | Behavior |
198
+ |---|---|
199
+ | `remote` | Re-fetch the remote version and overwrite the local file |
200
+ | `local` | Restore the pre-merge local content from the `.orig` backup |
201
+ | `merged` | Accept the current file contents as the resolved version (conflict markers must be removed first) |
202
+
203
+ ---
204
+
205
+ ## Configuration Reference
206
+
207
+ ### `backends.google_docs`
208
+
209
+ | Field | Type | Default | Description |
210
+ |---|---|---|---|
211
+ | `credentials_path` | string | null | Path to Google service account JSON key |
212
+ | `token_path` | string | `.markgate/google_token.json` | OAuth token storage path |
213
+
214
+ **Environment variable alternatives:**
215
+ - `ACCOUNT_A_CREDENTIALS_PATH` — path to service account JSON
216
+ - `ACCOUNT_A_CREDENTIALS` — inline service account JSON string
217
+
218
+ ### `backends.confluence`
219
+
220
+ | Field | Type | Default | Description |
221
+ |---|---|---|---|
222
+ | `base_url` | string | null | Confluence base URL, e.g. `https://yourorg.atlassian.net` |
223
+ | `username` | string | null | Atlassian account email |
224
+ | `api_token` | string | null | API token from id.atlassian.com |
225
+
226
+ **Environment variable alternatives:**
227
+ - `CONFLUENCE_BASE_URL`
228
+ - `ATLASSIAN_USER_NAME`
229
+ - `CONFLUENCE_API_TOKEN`
230
+
231
+ ### `mappings[]`
232
+
233
+ | Field | Type | Default | Required | Description |
234
+ |---|---|---|---|---|
235
+ | `local` | string | — | yes | Relative path to local markdown file |
236
+ | `backend` | string | — | yes | `"google_docs"` or `"confluence"` |
237
+ | `remote_id` | string | — | yes | Google Doc ID or Confluence page ID |
238
+ | `direction` | enum | `"both"` | no | `"push"`, `"pull"`, or `"both"` |
239
+
240
+ ---
241
+
242
+ ## State Files
243
+
244
+ docspan generates these files in your project directory after first sync:
245
+
246
+ | File | Description |
247
+ |---|---|
248
+ | `.markgate-state.json` | Sync state tracking (content hashes, remote versions) |
249
+ | `.markgate-base/` | Content-addressed store of merge bases |
250
+ | `{file}.orig` | Backup of local file before merge; deleted after conflict resolution |
251
+ | `{file}.comments.md` | Confluence comment sidecar; written during pull if comments exist |
252
+
253
+ ---
254
+
255
+ ## Known Limitations
256
+
257
+ > [!NOTE]
258
+ > **Known limitations in v0.1.0**
259
+ >
260
+ > - Google Docs: comments on edited paragraphs are lost on push (paragraph-level structural diff; comments on unchanged paragraphs are preserved)
261
+ > - Push: no image support — local images cannot be pushed to Google Docs or Confluence
262
+ > - Push: no table support — markdown tables are not rendered in Google Docs
263
+ > - Confluence: requires an Atlassian API token; no OAuth flow
264
+ > - Confluence: the comment sidecar (`{file}.comments.md`) is informational only; comments cannot be pushed back
265
+
266
+ ---
267
+
268
+ ## License
269
+
270
+ MIT. See [LICENSE](LICENSE) for details.
271
+
272
+ For contribution guidelines, see [CONTRIBUTING.md](https://github.com/tstapler/docspan/blob/main/CONTRIBUTING.md).
273
+ For the full change history, see [CHANGELOG.md](https://github.com/tstapler/docspan/blob/main/CHANGELOG.md).