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.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
[](https://pypi.org/project/docspan/)
|
|
53
|
+
[](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).
|