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
docspan/cli/main.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""docspan CLI — push, pull, auth, status, conflicts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from docspan.backends import BACKENDS
|
|
17
|
+
from docspan.config import MarkgateConfig, load_config
|
|
18
|
+
from docspan.core import (
|
|
19
|
+
MappingState,
|
|
20
|
+
SyncState,
|
|
21
|
+
get_base_content,
|
|
22
|
+
get_state_dir,
|
|
23
|
+
get_state_path,
|
|
24
|
+
orchestrate_pull,
|
|
25
|
+
orchestrate_push,
|
|
26
|
+
record_state,
|
|
27
|
+
)
|
|
28
|
+
from docspan.core.paths import ORIG_SUFFIX
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="docspan",
|
|
32
|
+
help="Push and pull markdown to Google Docs and Confluence.",
|
|
33
|
+
add_completion=False,
|
|
34
|
+
rich_markup_mode="rich",
|
|
35
|
+
)
|
|
36
|
+
auth_app = typer.Typer(help="Manage authentication for backends.")
|
|
37
|
+
conflicts_app = typer.Typer(help="Manage merge conflicts.")
|
|
38
|
+
app.add_typer(auth_app, name="auth")
|
|
39
|
+
app.add_typer(conflicts_app, name="conflicts")
|
|
40
|
+
|
|
41
|
+
console = Console()
|
|
42
|
+
err_console = Console(stderr=True, style="bold red")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
# Backend factory
|
|
47
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def _get_backend(backend_name: str, config: MarkgateConfig):
|
|
50
|
+
cls = BACKENDS.get(backend_name)
|
|
51
|
+
if not cls:
|
|
52
|
+
err_console.print(
|
|
53
|
+
f"Unknown backend '{backend_name}'. Available: {list(BACKENDS.keys())}"
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
backend = cls.from_config(config)
|
|
57
|
+
try:
|
|
58
|
+
backend.validate_config()
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
err_console.print(f"Configuration error: {exc}")
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
return backend
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_state(state_path: str) -> SyncState:
|
|
66
|
+
try:
|
|
67
|
+
return SyncState.load(state_path)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return SyncState()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
# push command
|
|
74
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def push(
|
|
78
|
+
files: Optional[list[str]] = typer.Argument(
|
|
79
|
+
None, help="Local markdown files to push (default: all mappings)"
|
|
80
|
+
),
|
|
81
|
+
config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to markgate.yaml"),
|
|
82
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Push local markdown to remote docs."""
|
|
85
|
+
config = load_config(config_path)
|
|
86
|
+
mappings = config.mappings
|
|
87
|
+
|
|
88
|
+
if files:
|
|
89
|
+
mappings = [m for m in mappings if m.local in files]
|
|
90
|
+
if not mappings:
|
|
91
|
+
err_console.print(f"No mappings found for: {files}")
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
94
|
+
if not mappings:
|
|
95
|
+
err_console.print("No mappings configured. Add entries to markgate.yaml.")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
state_path = get_state_path(config_path)
|
|
99
|
+
state_dir = get_state_dir(config_path)
|
|
100
|
+
state = _load_state(state_path)
|
|
101
|
+
|
|
102
|
+
had_error = False
|
|
103
|
+
for mapping in mappings:
|
|
104
|
+
if mapping.direction == "pull":
|
|
105
|
+
console.print(f"[dim]Skipping {mapping.local} (pull-only)[/dim]")
|
|
106
|
+
continue
|
|
107
|
+
if dry_run:
|
|
108
|
+
console.print(
|
|
109
|
+
f"[yellow]dry-run[/yellow] {mapping.local} → [{mapping.backend}] {mapping.remote_id}"
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
backend = _get_backend(mapping.backend, config)
|
|
114
|
+
outcome = orchestrate_push(mapping, backend, state, state_dir, state_path)
|
|
115
|
+
result = outcome.result
|
|
116
|
+
|
|
117
|
+
icon = "✓" if result.status in ("ok", "skipped") else "✗"
|
|
118
|
+
style = "green" if result.status in ("ok", "skipped") else "red"
|
|
119
|
+
console.print(f"[{style}]{icon}[/{style}] {mapping.local} → {result.url or mapping.remote_id}")
|
|
120
|
+
if result.message:
|
|
121
|
+
console.print(f" [dim]{result.message}[/dim]")
|
|
122
|
+
if result.status == "ok" and not outcome.state_saved:
|
|
123
|
+
console.print(" [yellow]Warning: could not save sync state[/yellow]")
|
|
124
|
+
if result.status == "error":
|
|
125
|
+
had_error = True
|
|
126
|
+
|
|
127
|
+
if had_error:
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
# pull command
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def pull(
|
|
137
|
+
files: Optional[list[str]] = typer.Argument(
|
|
138
|
+
None, help="Local paths to pull into (default: all mappings)"
|
|
139
|
+
),
|
|
140
|
+
config_path: Optional[str] = typer.Option(None, "--config", "-c"),
|
|
141
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Pull remote docs into local markdown files."""
|
|
144
|
+
config = load_config(config_path)
|
|
145
|
+
mappings = config.mappings
|
|
146
|
+
|
|
147
|
+
if files:
|
|
148
|
+
mappings = [m for m in mappings if m.local in files]
|
|
149
|
+
|
|
150
|
+
if not mappings:
|
|
151
|
+
err_console.print("No mappings configured.")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
state_path = get_state_path(config_path)
|
|
155
|
+
state_dir = get_state_dir(config_path)
|
|
156
|
+
state = _load_state(state_path)
|
|
157
|
+
|
|
158
|
+
had_error = False
|
|
159
|
+
for mapping in mappings:
|
|
160
|
+
if mapping.direction == "push":
|
|
161
|
+
console.print(f"[dim]Skipping {mapping.local} (push-only)[/dim]")
|
|
162
|
+
continue
|
|
163
|
+
if dry_run:
|
|
164
|
+
console.print(
|
|
165
|
+
f"[yellow]dry-run[/yellow] [{mapping.backend}] {mapping.remote_id} → {mapping.local}"
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
backend = _get_backend(mapping.backend, config)
|
|
170
|
+
outcome = orchestrate_pull(mapping, backend, state, state_dir, state_path)
|
|
171
|
+
|
|
172
|
+
if outcome.action == "up-to-date":
|
|
173
|
+
console.print(f"[dim]up to date[/dim] {mapping.local}")
|
|
174
|
+
elif outcome.action == "local-only":
|
|
175
|
+
console.print(
|
|
176
|
+
f"[yellow]warning[/yellow] {mapping.local} has local changes not yet pushed. "
|
|
177
|
+
"Pull skipped. Push first or use 'docspan conflicts resolve'."
|
|
178
|
+
)
|
|
179
|
+
elif outcome.action == "merged":
|
|
180
|
+
console.print(f"[yellow]merging[/yellow] {mapping.local}")
|
|
181
|
+
if outcome.has_conflicts:
|
|
182
|
+
console.print(
|
|
183
|
+
f" [yellow]Merge conflicts ({outcome.conflict_count}) written to "
|
|
184
|
+
f"{mapping.local}. Resolve with: docspan conflicts resolve {mapping.local}[/yellow]"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
console.print(" [green]Merged cleanly.[/green]")
|
|
188
|
+
elif outcome.action == "error":
|
|
189
|
+
had_error = True
|
|
190
|
+
result = outcome.result
|
|
191
|
+
err_console.print(
|
|
192
|
+
f"✗ {mapping.remote_id} → {mapping.local}: "
|
|
193
|
+
f"{result.message if result else 'unknown error'}"
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
# first-sync or fast-forward
|
|
197
|
+
result = outcome.result
|
|
198
|
+
if result:
|
|
199
|
+
icon = "✓" if result.status == "ok" else "✗"
|
|
200
|
+
style = "green" if result.status == "ok" else "red"
|
|
201
|
+
console.print(f"[{style}]{icon}[/{style}] {mapping.remote_id} → {mapping.local}")
|
|
202
|
+
if result.message:
|
|
203
|
+
console.print(f" [dim]{result.message}[/dim]")
|
|
204
|
+
|
|
205
|
+
if had_error:
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
# status command
|
|
211
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
@app.command()
|
|
214
|
+
def status(
|
|
215
|
+
config_path: Optional[str] = typer.Option(None, "--config", "-c"),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Show current mapping status."""
|
|
218
|
+
config = load_config(config_path)
|
|
219
|
+
|
|
220
|
+
if not config.mappings:
|
|
221
|
+
console.print("[yellow]No mappings configured.[/yellow] Add entries to markgate.yaml.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
table = Table(title="docspan mappings")
|
|
225
|
+
table.add_column("Local file", style="cyan")
|
|
226
|
+
table.add_column("Backend", style="magenta")
|
|
227
|
+
table.add_column("Remote ID")
|
|
228
|
+
table.add_column("Direction")
|
|
229
|
+
|
|
230
|
+
for m in config.mappings:
|
|
231
|
+
table.add_row(m.local, m.backend, m.remote_id, m.direction)
|
|
232
|
+
|
|
233
|
+
console.print(table)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
# auth subcommand
|
|
238
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
@auth_app.command("setup")
|
|
241
|
+
def auth_setup(
|
|
242
|
+
backend: str = typer.Argument(..., help="Backend to authenticate: google_docs | confluence"),
|
|
243
|
+
config_path: Optional[str] = typer.Option(None, "--config", "-c"),
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Interactive authentication setup for a backend."""
|
|
246
|
+
config = load_config(config_path)
|
|
247
|
+
cls = BACKENDS.get(backend)
|
|
248
|
+
if not cls:
|
|
249
|
+
err_console.print(f"Unknown backend '{backend}'. Available: {list(BACKENDS.keys())}")
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
b = cls.from_config(config)
|
|
252
|
+
b.auth_setup()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
# conflicts subcommand
|
|
257
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
@conflicts_app.command("list")
|
|
260
|
+
def conflicts_list(
|
|
261
|
+
config_path: Optional[str] = typer.Option(None, "--config", "-c"),
|
|
262
|
+
) -> None:
|
|
263
|
+
"""List files with unresolved merge conflicts."""
|
|
264
|
+
state_path = get_state_path(config_path)
|
|
265
|
+
state = _load_state(state_path)
|
|
266
|
+
|
|
267
|
+
conflicted = []
|
|
268
|
+
for local_path in state.mappings:
|
|
269
|
+
if not os.path.exists(local_path):
|
|
270
|
+
continue
|
|
271
|
+
with open(local_path, encoding="utf-8") as fh:
|
|
272
|
+
content = fh.read()
|
|
273
|
+
count = sum(1 for line in content.splitlines() if line.startswith("<<<<<<< "))
|
|
274
|
+
if count > 0:
|
|
275
|
+
conflicted.append((local_path, count))
|
|
276
|
+
|
|
277
|
+
if not conflicted:
|
|
278
|
+
console.print("No unresolved conflicts.")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
table = Table(title="Files with merge conflicts")
|
|
282
|
+
table.add_column("File", style="cyan")
|
|
283
|
+
table.add_column("Conflict blocks", style="red")
|
|
284
|
+
for local_path, count in conflicted:
|
|
285
|
+
table.add_row(local_path, str(count))
|
|
286
|
+
console.print(table)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@conflicts_app.command("resolve")
|
|
290
|
+
def conflicts_resolve(
|
|
291
|
+
file: str = typer.Argument(..., help="Local file path to resolve"),
|
|
292
|
+
accept: str = typer.Option(..., "--accept", help="Resolution strategy: remote | local | merged"),
|
|
293
|
+
config_path: Optional[str] = typer.Option(None, "--config", "-c"),
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Resolve a merge conflict in a tracked file."""
|
|
296
|
+
if accept not in ("remote", "local", "merged"):
|
|
297
|
+
err_console.print("--accept must be one of: remote, local, merged")
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
state_path = get_state_path(config_path)
|
|
301
|
+
state_dir = get_state_dir(config_path)
|
|
302
|
+
state = _load_state(state_path)
|
|
303
|
+
|
|
304
|
+
entry = state.get(file)
|
|
305
|
+
if entry is None:
|
|
306
|
+
err_console.print(f"File '{file}' is not tracked in .markgate-state.json")
|
|
307
|
+
raise typer.Exit(1)
|
|
308
|
+
|
|
309
|
+
config = load_config(config_path)
|
|
310
|
+
backend = _get_backend(entry.backend, config)
|
|
311
|
+
|
|
312
|
+
if accept == "remote":
|
|
313
|
+
_resolve_remote(file, entry, backend, state, state_path, state_dir)
|
|
314
|
+
elif accept == "local":
|
|
315
|
+
_resolve_local(file, entry, state, state_path, state_dir)
|
|
316
|
+
elif accept == "merged":
|
|
317
|
+
_resolve_merged(file, entry, state, state_path, state_dir)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _resolve_remote(
|
|
321
|
+
file: str,
|
|
322
|
+
entry: MappingState,
|
|
323
|
+
backend,
|
|
324
|
+
state: SyncState,
|
|
325
|
+
state_path: str,
|
|
326
|
+
state_dir: str,
|
|
327
|
+
) -> None:
|
|
328
|
+
result = backend.pull(entry.doc_id, file)
|
|
329
|
+
if result.status != "ok":
|
|
330
|
+
err_console.print(f"Could not re-fetch remote: {result.message}")
|
|
331
|
+
raise typer.Exit(1)
|
|
332
|
+
orig_path = file + ORIG_SUFFIX
|
|
333
|
+
if os.path.exists(orig_path):
|
|
334
|
+
os.unlink(orig_path)
|
|
335
|
+
with open(file, encoding="utf-8") as fh:
|
|
336
|
+
new_content = fh.read()
|
|
337
|
+
try:
|
|
338
|
+
remote_version = backend.get_remote_version(entry.doc_id)
|
|
339
|
+
except Exception:
|
|
340
|
+
logger.warning(
|
|
341
|
+
"Could not fetch remote version for %s after resolve; retaining stale version %s",
|
|
342
|
+
entry.doc_id, entry.remote_version, exc_info=True,
|
|
343
|
+
)
|
|
344
|
+
remote_version = entry.remote_version
|
|
345
|
+
record_state(state, state_path, state_dir, file, entry.doc_id, entry.backend, new_content, remote_version)
|
|
346
|
+
console.print(f"[green]Resolved[/green] {file} (accepted remote)")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _resolve_local(
|
|
350
|
+
file: str,
|
|
351
|
+
entry: MappingState,
|
|
352
|
+
state: SyncState,
|
|
353
|
+
state_path: str,
|
|
354
|
+
state_dir: str,
|
|
355
|
+
) -> None:
|
|
356
|
+
orig_path = file + ORIG_SUFFIX
|
|
357
|
+
if os.path.exists(orig_path):
|
|
358
|
+
shutil.copy2(orig_path, file)
|
|
359
|
+
os.unlink(orig_path)
|
|
360
|
+
console.print(f"[green]Restored[/green] {file} from {orig_path}")
|
|
361
|
+
else:
|
|
362
|
+
base_content = get_base_content(state_dir, entry.base_hash)
|
|
363
|
+
if base_content:
|
|
364
|
+
with open(file, "w", encoding="utf-8") as fh:
|
|
365
|
+
fh.write(base_content)
|
|
366
|
+
console.print("[yellow]Warning:[/yellow] .orig not found; restored from base content")
|
|
367
|
+
else:
|
|
368
|
+
err_console.print(f"No .orig file and no base content for '{file}'. Cannot restore.")
|
|
369
|
+
raise typer.Exit(1)
|
|
370
|
+
with open(file, encoding="utf-8") as fh:
|
|
371
|
+
new_content = fh.read()
|
|
372
|
+
record_state(state, state_path, state_dir, file, entry.doc_id, entry.backend, new_content, entry.remote_version)
|
|
373
|
+
console.print(f"[green]Resolved[/green] {file} (accepted local)")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _resolve_merged(
|
|
377
|
+
file: str,
|
|
378
|
+
entry: MappingState,
|
|
379
|
+
state: SyncState,
|
|
380
|
+
state_path: str,
|
|
381
|
+
state_dir: str,
|
|
382
|
+
) -> None:
|
|
383
|
+
if not os.path.exists(file):
|
|
384
|
+
err_console.print(f"File '{file}' does not exist")
|
|
385
|
+
raise typer.Exit(1)
|
|
386
|
+
with open(file, encoding="utf-8") as fh:
|
|
387
|
+
content = fh.read()
|
|
388
|
+
if "<<<<<<< " in content:
|
|
389
|
+
err_console.print(
|
|
390
|
+
f"File '{file}' still contains conflict markers. "
|
|
391
|
+
"Resolve all conflicts before accepting as merged."
|
|
392
|
+
)
|
|
393
|
+
raise typer.Exit(1)
|
|
394
|
+
record_state(state, state_path, state_dir, file, entry.doc_id, entry.backend, content, entry.remote_version)
|
|
395
|
+
console.print(f"[green]Resolved[/green] {file} (accepted merged)")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
400
|
+
# Entry point
|
|
401
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
def main() -> None:
|
|
404
|
+
app()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
if __name__ == "__main__":
|
|
408
|
+
main()
|
docspan/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""markgate.yaml loader and config model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
CONFIG_FILENAME = "markgate.yaml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GoogleDocsConfig(BaseModel):
|
|
16
|
+
credentials_path: Optional[str] = None
|
|
17
|
+
token_path: Optional[str] = ".markgate/google_token.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfluenceConfig(BaseModel):
|
|
21
|
+
base_url: Optional[str] = None
|
|
22
|
+
username: Optional[str] = None
|
|
23
|
+
api_token: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BackendsConfig(BaseModel):
|
|
27
|
+
google_docs: Optional[GoogleDocsConfig] = None
|
|
28
|
+
confluence: Optional[ConfluenceConfig] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Mapping(BaseModel):
|
|
32
|
+
local: str # relative path to local markdown file
|
|
33
|
+
backend: str # "google_docs" or "confluence"
|
|
34
|
+
remote_id: str # Google Doc ID or Confluence page ID
|
|
35
|
+
direction: Literal["push", "pull", "both"] = "both"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MarkgateConfig(BaseModel):
|
|
39
|
+
backends: BackendsConfig = BackendsConfig()
|
|
40
|
+
mappings: list[Mapping] = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config(path: Optional[str] = None) -> MarkgateConfig:
|
|
44
|
+
"""Load markgate.yaml, falling back to env vars for credentials."""
|
|
45
|
+
config_path = pathlib.Path(path or CONFIG_FILENAME)
|
|
46
|
+
|
|
47
|
+
raw: dict = {}
|
|
48
|
+
if config_path.exists():
|
|
49
|
+
with open(config_path) as f:
|
|
50
|
+
raw = yaml.safe_load(f) or {}
|
|
51
|
+
|
|
52
|
+
# Env var overrides for Confluence (backwards compat with markdown-confluence)
|
|
53
|
+
if "backends" not in raw:
|
|
54
|
+
raw["backends"] = {}
|
|
55
|
+
if "confluence" not in raw["backends"]:
|
|
56
|
+
raw["backends"]["confluence"] = {}
|
|
57
|
+
cf = raw["backends"]["confluence"]
|
|
58
|
+
cf.setdefault("base_url", os.getenv("CONFLUENCE_BASE_URL"))
|
|
59
|
+
cf.setdefault("username", os.getenv("ATLASSIAN_USER_NAME"))
|
|
60
|
+
cf.setdefault("api_token", os.getenv("CONFLUENCE_API_TOKEN"))
|
|
61
|
+
|
|
62
|
+
return MarkgateConfig(**raw)
|
docspan/core/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from docspan.core.merge import MergeResult, three_way_merge
|
|
2
|
+
from docspan.core.orchestrator import (
|
|
3
|
+
PullOutcome,
|
|
4
|
+
PushOutcome,
|
|
5
|
+
get_base_content,
|
|
6
|
+
get_state_dir,
|
|
7
|
+
get_state_path,
|
|
8
|
+
orchestrate_pull,
|
|
9
|
+
orchestrate_push,
|
|
10
|
+
record_state,
|
|
11
|
+
save_base_content,
|
|
12
|
+
)
|
|
13
|
+
from docspan.core.paths import (
|
|
14
|
+
BASE_FILE_SUFFIX,
|
|
15
|
+
BASE_STORE_DIR,
|
|
16
|
+
COMMENTS_SUFFIX,
|
|
17
|
+
GOOGLE_TOKEN_PATH,
|
|
18
|
+
ORIG_SUFFIX,
|
|
19
|
+
STATE_FILENAME,
|
|
20
|
+
)
|
|
21
|
+
from docspan.core.state import MappingState, SyncState, sha256_of_content, sha256_of_file
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# state
|
|
25
|
+
"SyncState",
|
|
26
|
+
"MappingState",
|
|
27
|
+
"sha256_of_file",
|
|
28
|
+
"sha256_of_content",
|
|
29
|
+
# merge
|
|
30
|
+
"MergeResult",
|
|
31
|
+
"three_way_merge",
|
|
32
|
+
# orchestrator
|
|
33
|
+
"PushOutcome",
|
|
34
|
+
"PullOutcome",
|
|
35
|
+
"get_state_path",
|
|
36
|
+
"get_state_dir",
|
|
37
|
+
"get_base_content",
|
|
38
|
+
"save_base_content",
|
|
39
|
+
"orchestrate_push",
|
|
40
|
+
"orchestrate_pull",
|
|
41
|
+
"record_state",
|
|
42
|
+
# paths
|
|
43
|
+
"STATE_FILENAME",
|
|
44
|
+
"BASE_STORE_DIR",
|
|
45
|
+
"BASE_FILE_SUFFIX",
|
|
46
|
+
"ORIG_SUFFIX",
|
|
47
|
+
"COMMENTS_SUFFIX",
|
|
48
|
+
"GOOGLE_TOKEN_PATH",
|
|
49
|
+
]
|
docspan/core/merge.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Three-way merge using the merge3 package."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class MergeResult:
|
|
9
|
+
merged: str
|
|
10
|
+
has_conflicts: bool
|
|
11
|
+
conflict_count: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def three_way_merge(base: str, theirs: str, ours: str) -> MergeResult:
|
|
15
|
+
from merge3 import Merge3
|
|
16
|
+
|
|
17
|
+
base_lines = base.splitlines(keepends=True)
|
|
18
|
+
ours_lines = ours.splitlines(keepends=True)
|
|
19
|
+
theirs_lines = theirs.splitlines(keepends=True)
|
|
20
|
+
m3 = Merge3(base_lines, ours_lines, theirs_lines)
|
|
21
|
+
merged_lines = list(m3.merge_lines(
|
|
22
|
+
name_a="ours",
|
|
23
|
+
name_b="theirs",
|
|
24
|
+
start_marker="<<<<<<< ours",
|
|
25
|
+
mid_marker="=======",
|
|
26
|
+
end_marker=">>>>>>> theirs",
|
|
27
|
+
))
|
|
28
|
+
merged = "".join(merged_lines)
|
|
29
|
+
conflicts = sum(1 for line in merged_lines if line.startswith("<<<<<<<"))
|
|
30
|
+
return MergeResult(merged=merged, has_conflicts=conflicts > 0, conflict_count=conflicts)
|