confpub-cli 1.2.0__tar.gz → 1.4.1__tar.gz
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.
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/PKG-INFO +1 -1
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/__init__.py +1 -1
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/applier.py +21 -1
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/cli.py +189 -9
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/confluence.py +133 -6
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/converter.py +21 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/errors.py +1 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/guide.py +56 -2
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/manifest.py +12 -1
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/output.py +11 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/planner.py +4 -1
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/publish.py +31 -8
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/puller.py +18 -4
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_applier.py +85 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_confluence.py +190 -2
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_converter.py +29 -1
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_guide.py +45 -3
- confpub_cli-1.4.1/tests/test_integration.py +449 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_manifest.py +83 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_output.py +16 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_publish.py +94 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_puller.py +50 -0
- confpub_cli-1.2.0/tests/test_integration.py +0 -222
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/.gitignore +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/CLAUDE.md +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/LICENSE +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/PRD.md +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/README.md +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/assets.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/config.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/envelope.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/lockfile.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/py.typed +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/validator.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub/verifier.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/confpub.lock +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/pyproject.toml +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/__init__.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/conftest.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_assets.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_config.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_envelope.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_errors.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_planner.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_validator.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/tests/test_verifier.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.4.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: Agent-first CLI to publish Markdown to Confluence
|
|
5
5
|
Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
|
|
@@ -43,7 +43,7 @@ def apply_plan(
|
|
|
43
43
|
lockfile = load_lockfile(lockfile_path) or Lockfile()
|
|
44
44
|
|
|
45
45
|
changes: list[dict[str, Any]] = []
|
|
46
|
-
counts = {"create": 0, "update": 0, "attachments_upload": 0}
|
|
46
|
+
counts = {"create": 0, "update": 0, "attachments_upload": 0, "labels_applied": 0}
|
|
47
47
|
|
|
48
48
|
# Resolve parent page IDs by title
|
|
49
49
|
parent_ids: dict[str, str] = {} # title → page_id
|
|
@@ -128,9 +128,19 @@ def apply_plan(
|
|
|
128
128
|
change["attachments_added"] = [a.source_path for a in assets]
|
|
129
129
|
counts["attachments_upload"] += len(assets)
|
|
130
130
|
|
|
131
|
+
# Apply labels
|
|
132
|
+
if page.labels:
|
|
133
|
+
client.set_labels(new_id, page.labels)
|
|
134
|
+
change["labels_added"] = page.labels
|
|
135
|
+
counts["labels_applied"] += len(page.labels)
|
|
136
|
+
|
|
131
137
|
# Update lockfile and parent tracking
|
|
132
138
|
update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
|
|
133
139
|
parent_ids[page.title] = new_id
|
|
140
|
+
else:
|
|
141
|
+
# Dry-run: report labels
|
|
142
|
+
if page.labels:
|
|
143
|
+
change["labels_to_apply"] = page.labels
|
|
134
144
|
|
|
135
145
|
counts["create"] += 1
|
|
136
146
|
changes.append(change)
|
|
@@ -184,12 +194,22 @@ def apply_plan(
|
|
|
184
194
|
change["attachments_added"] = [a.source_path for a in assets]
|
|
185
195
|
counts["attachments_upload"] += len(assets)
|
|
186
196
|
|
|
197
|
+
# Apply labels
|
|
198
|
+
if page.labels:
|
|
199
|
+
client.set_labels(page.confluence_page_id, page.labels)
|
|
200
|
+
change["labels_added"] = page.labels
|
|
201
|
+
counts["labels_applied"] += len(page.labels)
|
|
202
|
+
|
|
187
203
|
update_lockfile(
|
|
188
204
|
lockfile, page.title, page.confluence_page_id,
|
|
189
205
|
new_version if isinstance(new_version, int) else 1,
|
|
190
206
|
content_fingerprint=fingerprint_content(storage),
|
|
191
207
|
)
|
|
192
208
|
parent_ids[page.title] = page.confluence_page_id
|
|
209
|
+
else:
|
|
210
|
+
# Dry-run: report labels
|
|
211
|
+
if page.labels:
|
|
212
|
+
change["labels_to_apply"] = page.labels
|
|
193
213
|
|
|
194
214
|
counts["update"] += 1
|
|
195
215
|
changes.append(change)
|
|
@@ -16,7 +16,7 @@ import typer
|
|
|
16
16
|
from confpub import __version__
|
|
17
17
|
from confpub.envelope import Envelope
|
|
18
18
|
from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
|
|
19
|
-
from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_verbose
|
|
19
|
+
from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
|
|
20
20
|
|
|
21
21
|
# ---------------------------------------------------------------------------
|
|
22
22
|
# Subcommand group apps
|
|
@@ -26,10 +26,12 @@ from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_
|
|
|
26
26
|
def _group_callback(
|
|
27
27
|
quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
|
|
28
28
|
verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
|
|
29
|
+
compact: bool = typer.Option(False, "--compact", help="Output single-line JSON (no indentation)"),
|
|
29
30
|
) -> None:
|
|
30
|
-
"""Allow --quiet/--verbose between the group name and the subcommand."""
|
|
31
|
+
"""Allow --quiet/--verbose/--compact between the group name and the subcommand."""
|
|
31
32
|
set_quiet(quiet)
|
|
32
33
|
set_verbose(verbose)
|
|
34
|
+
set_compact(compact)
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
page_app = typer.Typer(help="Page operations", callback=_group_callback)
|
|
@@ -38,6 +40,8 @@ auth_app = typer.Typer(help="Authentication", callback=_group_callback)
|
|
|
38
40
|
config_app = typer.Typer(help="Configuration", callback=_group_callback)
|
|
39
41
|
space_app = typer.Typer(help="Space operations", callback=_group_callback)
|
|
40
42
|
attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
|
|
43
|
+
label_app = typer.Typer(help="Label operations", callback=_group_callback)
|
|
44
|
+
comment_app = typer.Typer(help="Comment operations", callback=_group_callback)
|
|
41
45
|
|
|
42
46
|
# ---------------------------------------------------------------------------
|
|
43
47
|
# Main app
|
|
@@ -55,6 +59,8 @@ app.add_typer(auth_app, name="auth")
|
|
|
55
59
|
app.add_typer(config_app, name="config")
|
|
56
60
|
app.add_typer(space_app, name="space")
|
|
57
61
|
app.add_typer(attachment_app, name="attachment")
|
|
62
|
+
app.add_typer(label_app, name="label")
|
|
63
|
+
app.add_typer(comment_app, name="comment")
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
def _version_callback(value: bool) -> None:
|
|
@@ -67,6 +73,7 @@ def _version_callback(value: bool) -> None:
|
|
|
67
73
|
def main_callback(
|
|
68
74
|
quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
|
|
69
75
|
verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
|
|
76
|
+
compact: bool = typer.Option(False, "--compact", help="Output single-line JSON (no indentation)"),
|
|
70
77
|
version: bool = typer.Option(
|
|
71
78
|
False, "--version", help="Show version and exit",
|
|
72
79
|
callback=_version_callback, is_eager=True,
|
|
@@ -75,6 +82,7 @@ def main_callback(
|
|
|
75
82
|
"""confpub — publish Markdown to Confluence."""
|
|
76
83
|
set_quiet(quiet)
|
|
77
84
|
set_verbose(verbose)
|
|
85
|
+
set_compact(compact)
|
|
78
86
|
|
|
79
87
|
|
|
80
88
|
# ---------------------------------------------------------------------------
|
|
@@ -90,6 +98,7 @@ class CommandResult:
|
|
|
90
98
|
self.target: dict[str, Any] | None = None
|
|
91
99
|
self.warnings: list[str] = []
|
|
92
100
|
self.metrics: dict[str, Any] = {}
|
|
101
|
+
self.client: Any = None
|
|
93
102
|
|
|
94
103
|
|
|
95
104
|
@contextmanager
|
|
@@ -113,7 +122,10 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
113
122
|
ctx.metrics["duration_ms"] = duration_ms
|
|
114
123
|
if is_verbose():
|
|
115
124
|
import traceback as tb
|
|
116
|
-
|
|
125
|
+
err_diag: dict[str, Any] = {"traceback": tb.format_exc()}
|
|
126
|
+
if ctx.client and hasattr(ctx.client, "_call_count"):
|
|
127
|
+
err_diag["api_call_count"] = ctx.client._call_count
|
|
128
|
+
ctx.metrics["diagnostics"] = err_diag
|
|
117
129
|
envelope = Envelope.failure(
|
|
118
130
|
command_name,
|
|
119
131
|
[e],
|
|
@@ -121,7 +133,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
121
133
|
warnings=ctx.warnings,
|
|
122
134
|
metrics=ctx.metrics,
|
|
123
135
|
)
|
|
124
|
-
emit_stdout(envelope.to_json_bytes())
|
|
136
|
+
emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
|
|
125
137
|
raise typer.Exit(code=exit_code_for(e.code))
|
|
126
138
|
except typer.Exit:
|
|
127
139
|
raise
|
|
@@ -141,7 +153,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
141
153
|
warnings=ctx.warnings,
|
|
142
154
|
metrics=ctx.metrics,
|
|
143
155
|
)
|
|
144
|
-
emit_stdout(envelope.to_json_bytes())
|
|
156
|
+
emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
|
|
145
157
|
raise typer.Exit(code=90)
|
|
146
158
|
else:
|
|
147
159
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
@@ -151,15 +163,20 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
151
163
|
from confpub.config import load_config as _load_verbose_config
|
|
152
164
|
|
|
153
165
|
diag: dict[str, Any] = {
|
|
166
|
+
"duration_ms": duration_ms,
|
|
154
167
|
"command": command_name,
|
|
155
168
|
"target": ctx.target,
|
|
156
169
|
"warning_count": len(ctx.warnings),
|
|
157
170
|
"python_version": sys.version,
|
|
158
171
|
"confpub_version": __version__,
|
|
159
172
|
}
|
|
173
|
+
if ctx.client and hasattr(ctx.client, "_call_count"):
|
|
174
|
+
diag["api_call_count"] = ctx.client._call_count
|
|
160
175
|
try:
|
|
161
176
|
_vcfg = _load_verbose_config()
|
|
177
|
+
diag["config_source"] = _vcfg.token_source
|
|
162
178
|
diag["confluence_url"] = _vcfg.base_url
|
|
179
|
+
diag["is_cloud"] = _vcfg.is_cloud
|
|
163
180
|
except Exception:
|
|
164
181
|
pass
|
|
165
182
|
ctx.metrics["diagnostics"] = diag
|
|
@@ -170,7 +187,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
170
187
|
warnings=ctx.warnings,
|
|
171
188
|
metrics=ctx.metrics,
|
|
172
189
|
)
|
|
173
|
-
emit_stdout(envelope.to_json_bytes())
|
|
190
|
+
emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
|
|
174
191
|
|
|
175
192
|
|
|
176
193
|
# ---------------------------------------------------------------------------
|
|
@@ -188,8 +205,15 @@ def page_list(
|
|
|
188
205
|
with command_context("page.list", target={"space": space}) as ctx:
|
|
189
206
|
from confpub.confluence import build_client, _slim_page
|
|
190
207
|
client = build_client()
|
|
191
|
-
|
|
192
|
-
|
|
208
|
+
ctx.client = client
|
|
209
|
+
page_result = client.list_pages(space, start=start, limit=limit)
|
|
210
|
+
ctx.result = {
|
|
211
|
+
"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in page_result["pages"]],
|
|
212
|
+
"start": page_result["start"],
|
|
213
|
+
"limit": page_result["limit"],
|
|
214
|
+
"size": page_result["size"],
|
|
215
|
+
"has_more": page_result["has_more"],
|
|
216
|
+
}
|
|
193
217
|
|
|
194
218
|
|
|
195
219
|
@page_app.command("inspect")
|
|
@@ -204,6 +228,7 @@ def page_inspect(
|
|
|
204
228
|
with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
205
229
|
from confpub.confluence import build_client, _slim_page
|
|
206
230
|
client = build_client()
|
|
231
|
+
ctx.client = client
|
|
207
232
|
if page_id:
|
|
208
233
|
page = client.get_page_by_id(page_id)
|
|
209
234
|
else:
|
|
@@ -236,13 +261,15 @@ def page_publish(
|
|
|
236
261
|
space: str = typer.Option(..., "--space", help="Confluence space key"),
|
|
237
262
|
parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
|
|
238
263
|
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
|
|
264
|
+
title_from_h1: bool = typer.Option(False, "--title-from-h1", help="Derive title from first H1 heading in the Markdown file"),
|
|
239
265
|
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
|
|
240
266
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
241
267
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
268
|
+
label: Optional[list[str]] = typer.Option(None, "--label", help="Label to apply (repeatable)"),
|
|
242
269
|
) -> None:
|
|
243
270
|
"""Publish a single Markdown file to Confluence."""
|
|
244
271
|
from confpub.publish import derive_title
|
|
245
|
-
resolved_title = derive_title(file, title)
|
|
272
|
+
resolved_title = derive_title(file, title, title_from_h1=title_from_h1)
|
|
246
273
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
247
274
|
if page_id:
|
|
248
275
|
target["page_id"] = page_id
|
|
@@ -262,6 +289,7 @@ def page_publish(
|
|
|
262
289
|
dry_run=dry_run,
|
|
263
290
|
backup=backup,
|
|
264
291
|
progress_callback=ctx,
|
|
292
|
+
labels=label or [],
|
|
265
293
|
)
|
|
266
294
|
ctx.result = result
|
|
267
295
|
|
|
@@ -319,6 +347,7 @@ def page_delete(
|
|
|
319
347
|
)
|
|
320
348
|
from confpub.confluence import build_client
|
|
321
349
|
client = build_client()
|
|
350
|
+
ctx.client = client
|
|
322
351
|
|
|
323
352
|
# Collect descendant IDs before deleting (for lockfile cleanup)
|
|
324
353
|
deleted_ids: set[str] = set()
|
|
@@ -351,12 +380,51 @@ def page_delete(
|
|
|
351
380
|
ctx.result = result
|
|
352
381
|
|
|
353
382
|
|
|
383
|
+
@page_app.command("move")
|
|
384
|
+
def page_move(
|
|
385
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
|
|
386
|
+
target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
|
|
387
|
+
space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
|
|
388
|
+
target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Move a page under a new parent."""
|
|
391
|
+
target = {"page_id": page_id}
|
|
392
|
+
with command_context("page.move", target=target) as ctx:
|
|
393
|
+
if not target_parent and not target_parent_id:
|
|
394
|
+
raise ConfpubError(
|
|
395
|
+
"ERR_VALIDATION_REQUIRED",
|
|
396
|
+
"Either --target-parent or --target-parent-id is required",
|
|
397
|
+
)
|
|
398
|
+
if target_parent and not space:
|
|
399
|
+
raise ConfpubError(
|
|
400
|
+
"ERR_VALIDATION_REQUIRED",
|
|
401
|
+
"--space is required when using --target-parent",
|
|
402
|
+
)
|
|
403
|
+
from confpub.confluence import build_client
|
|
404
|
+
client = build_client()
|
|
405
|
+
ctx.client = client
|
|
406
|
+
|
|
407
|
+
if target_parent_id:
|
|
408
|
+
# Use target_id directly — more reliable, no title resolution needed
|
|
409
|
+
parent_page = client.get_page_by_id(target_parent_id)
|
|
410
|
+
if not parent_page or not parent_page.get("id"):
|
|
411
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
412
|
+
raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Target parent page not found: {target_parent_id}")
|
|
413
|
+
resolved_space = parent_page.get("space", {}).get("key", space or "")
|
|
414
|
+
result = client.move_page(resolved_space, page_id, target_id=target_parent_id)
|
|
415
|
+
else:
|
|
416
|
+
result = client.move_page(space, page_id, target_title=target_parent)
|
|
417
|
+
|
|
418
|
+
ctx.result = result
|
|
419
|
+
|
|
420
|
+
|
|
354
421
|
@space_app.command("list")
|
|
355
422
|
def space_list() -> None:
|
|
356
423
|
"""List accessible Confluence spaces."""
|
|
357
424
|
with command_context("space.list") as ctx:
|
|
358
425
|
from confpub.confluence import build_client
|
|
359
426
|
client = build_client()
|
|
427
|
+
ctx.client = client
|
|
360
428
|
spaces = client.list_spaces()
|
|
361
429
|
ctx.result = {"spaces": spaces}
|
|
362
430
|
|
|
@@ -369,6 +437,7 @@ def attachment_list(
|
|
|
369
437
|
with command_context("attachment.list", target={"page_id": page_id}) as ctx:
|
|
370
438
|
from confpub.confluence import build_client, _slim_attachment
|
|
371
439
|
client = build_client()
|
|
440
|
+
ctx.client = client
|
|
372
441
|
attachments = client.get_attachments(page_id)
|
|
373
442
|
ctx.result = {"attachments": [_slim_attachment(a) for a in attachments]}
|
|
374
443
|
|
|
@@ -382,6 +451,7 @@ def attachment_upload(
|
|
|
382
451
|
with command_context("attachment.upload", target={"page_id": page_id, "file": file}) as ctx:
|
|
383
452
|
from confpub.confluence import build_client
|
|
384
453
|
client = build_client()
|
|
454
|
+
ctx.client = client
|
|
385
455
|
result = client.upload_attachment(page_id, file)
|
|
386
456
|
ctx.result = result
|
|
387
457
|
|
|
@@ -481,6 +551,115 @@ def config_inspect() -> None:
|
|
|
481
551
|
ctx.result = config.to_display_dict()
|
|
482
552
|
|
|
483
553
|
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
# Label commands
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@label_app.command("list")
|
|
560
|
+
def label_list(
|
|
561
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
562
|
+
) -> None:
|
|
563
|
+
"""List labels on a Confluence page."""
|
|
564
|
+
with command_context("label.list", target={"page_id": page_id}) as ctx:
|
|
565
|
+
from confpub.confluence import build_client
|
|
566
|
+
client = build_client()
|
|
567
|
+
ctx.client = client
|
|
568
|
+
labels = client.get_labels(page_id)
|
|
569
|
+
ctx.result = {"labels": labels, "count": len(labels)}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@label_app.command("add")
|
|
573
|
+
def label_add(
|
|
574
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
575
|
+
label: list[str] = typer.Option(..., "--label", help="Label name (repeatable)"),
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Add labels to a Confluence page."""
|
|
578
|
+
with command_context("label.add", target={"page_id": page_id}) as ctx:
|
|
579
|
+
from confpub.errors import ERR_VALIDATION_LABEL
|
|
580
|
+
# Validate labels
|
|
581
|
+
for lbl in label:
|
|
582
|
+
if " " in lbl:
|
|
583
|
+
raise ConfpubError(
|
|
584
|
+
ERR_VALIDATION_LABEL,
|
|
585
|
+
f"Label must not contain spaces: '{lbl}'",
|
|
586
|
+
details={"label": lbl},
|
|
587
|
+
)
|
|
588
|
+
if len(lbl) > 255:
|
|
589
|
+
raise ConfpubError(
|
|
590
|
+
ERR_VALIDATION_LABEL,
|
|
591
|
+
f"Label exceeds 255 characters: '{lbl[:50]}...'",
|
|
592
|
+
details={"label": lbl, "length": len(lbl)},
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
from confpub.confluence import build_client
|
|
596
|
+
client = build_client()
|
|
597
|
+
ctx.client = client
|
|
598
|
+
results = client.set_labels(page_id, label)
|
|
599
|
+
ctx.result = {"labels_added": label, "results": results}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@label_app.command("remove")
|
|
603
|
+
def label_remove(
|
|
604
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
605
|
+
label: list[str] = typer.Option(..., "--label", help="Label name to remove (repeatable)"),
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Remove labels from a Confluence page."""
|
|
608
|
+
with command_context("label.remove", target={"page_id": page_id}) as ctx:
|
|
609
|
+
from confpub.confluence import build_client
|
|
610
|
+
client = build_client()
|
|
611
|
+
ctx.client = client
|
|
612
|
+
results = []
|
|
613
|
+
for lbl in label:
|
|
614
|
+
result = client.remove_label(page_id, lbl)
|
|
615
|
+
results.append(result)
|
|
616
|
+
ctx.result = {"labels_removed": label, "results": results}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
# Comment commands
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@comment_app.command("add")
|
|
625
|
+
def comment_add(
|
|
626
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
627
|
+
text: Optional[str] = typer.Option(None, "--text", help="Comment text (Markdown)"),
|
|
628
|
+
file: Optional[str] = typer.Option(None, "--file", help="Path to Markdown file for comment body"),
|
|
629
|
+
) -> None:
|
|
630
|
+
"""Add a comment to a Confluence page."""
|
|
631
|
+
with command_context("comment.add", target={"page_id": page_id}) as ctx:
|
|
632
|
+
if not text and not file:
|
|
633
|
+
raise ConfpubError(
|
|
634
|
+
"ERR_VALIDATION_REQUIRED",
|
|
635
|
+
"Either --text or --file is required",
|
|
636
|
+
)
|
|
637
|
+
if text and file:
|
|
638
|
+
raise ConfpubError(
|
|
639
|
+
"ERR_VALIDATION_REQUIRED",
|
|
640
|
+
"--text and --file are mutually exclusive",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if file:
|
|
644
|
+
from pathlib import Path
|
|
645
|
+
p = Path(file)
|
|
646
|
+
if not p.exists():
|
|
647
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
648
|
+
raise ConfpubError(ERR_IO_FILE_NOT_FOUND, f"File not found: {file}")
|
|
649
|
+
md_text = p.read_text(encoding="utf-8")
|
|
650
|
+
else:
|
|
651
|
+
md_text = text
|
|
652
|
+
|
|
653
|
+
from confpub.converter import convert_markdown
|
|
654
|
+
storage_body = convert_markdown(md_text)
|
|
655
|
+
|
|
656
|
+
from confpub.confluence import build_client
|
|
657
|
+
client = build_client()
|
|
658
|
+
ctx.client = client
|
|
659
|
+
result = client.add_comment(page_id, storage_body)
|
|
660
|
+
ctx.result = result
|
|
661
|
+
|
|
662
|
+
|
|
484
663
|
# ---------------------------------------------------------------------------
|
|
485
664
|
# search command (top-level, not in a subgroup)
|
|
486
665
|
# ---------------------------------------------------------------------------
|
|
@@ -521,6 +700,7 @@ def search(
|
|
|
521
700
|
|
|
522
701
|
from confpub.confluence import build_client
|
|
523
702
|
client = build_client()
|
|
703
|
+
ctx.client = client
|
|
524
704
|
result = client.search(
|
|
525
705
|
effective_cql,
|
|
526
706
|
start=start,
|
|
@@ -63,7 +63,7 @@ class ConfluenceClient:
|
|
|
63
63
|
raise ConfpubError(
|
|
64
64
|
ERR_AUTH_FORBIDDEN,
|
|
65
65
|
f"Permission denied: {msg}",
|
|
66
|
-
suggested_action="
|
|
66
|
+
suggested_action="check_input",
|
|
67
67
|
details={"note": "Confluence returns HTTP 403 for both forbidden and nonexistent resources. Verify the resource exists."},
|
|
68
68
|
) from exc
|
|
69
69
|
if "timeout" in msg.lower() or "Timeout" in msg:
|
|
@@ -75,6 +75,7 @@ class ConfluenceClient:
|
|
|
75
75
|
raise ConfpubError(
|
|
76
76
|
ERR_AUTH_FORBIDDEN,
|
|
77
77
|
f"Permission denied ({context}): {msg}",
|
|
78
|
+
suggested_action="check_input",
|
|
78
79
|
details={"note": "This may indicate a nonexistent resource; Confluence returns 403 for both."},
|
|
79
80
|
) from exc
|
|
80
81
|
# Not found (404 or explicit "not found")
|
|
@@ -273,17 +274,27 @@ class ConfluenceClient:
|
|
|
273
274
|
self._handle_error(exc, "list_spaces")
|
|
274
275
|
return []
|
|
275
276
|
|
|
276
|
-
def list_pages(self, space: str, *, start: int = 0, limit: int = 25) ->
|
|
277
|
-
"""List pages in a space.
|
|
277
|
+
def list_pages(self, space: str, *, start: int = 0, limit: int = 25) -> dict[str, Any]:
|
|
278
|
+
"""List pages in a space.
|
|
279
|
+
|
|
280
|
+
Returns a dict with keys: pages, start, limit, size, has_more.
|
|
281
|
+
"""
|
|
278
282
|
self._call_count += 1
|
|
279
283
|
try:
|
|
280
284
|
result = self._api.get_all_pages_from_space(
|
|
281
285
|
space, start=start, limit=limit, expand="version",
|
|
282
286
|
)
|
|
283
|
-
|
|
287
|
+
pages = result if isinstance(result, list) else []
|
|
288
|
+
return {
|
|
289
|
+
"pages": pages,
|
|
290
|
+
"start": start,
|
|
291
|
+
"limit": limit,
|
|
292
|
+
"size": len(pages),
|
|
293
|
+
"has_more": len(pages) >= limit,
|
|
294
|
+
}
|
|
284
295
|
except Exception as exc:
|
|
285
296
|
self._handle_error(exc, "list_pages")
|
|
286
|
-
return []
|
|
297
|
+
return {"pages": [], "start": start, "limit": limit, "size": 0, "has_more": False}
|
|
287
298
|
|
|
288
299
|
# ------------------------------------------------------------------
|
|
289
300
|
# Attachment operations
|
|
@@ -351,7 +362,12 @@ class ConfluenceClient:
|
|
|
351
362
|
"""Upload an attachment to a page."""
|
|
352
363
|
self._call_count += 1
|
|
353
364
|
try:
|
|
354
|
-
|
|
365
|
+
import mimetypes
|
|
366
|
+
content_type, _ = mimetypes.guess_type(filepath)
|
|
367
|
+
kwargs: dict[str, Any] = {"page_id": page_id}
|
|
368
|
+
if content_type:
|
|
369
|
+
kwargs["content_type"] = content_type
|
|
370
|
+
result = self._api.attach_file(filepath, **kwargs)
|
|
355
371
|
if isinstance(result, dict):
|
|
356
372
|
# API returns {"results": [...]} wrapper — extract the attachment
|
|
357
373
|
if "results" in result and isinstance(result["results"], list) and result["results"]:
|
|
@@ -419,6 +435,108 @@ class ConfluenceClient:
|
|
|
419
435
|
"has_more": (api_start + api_limit) < total,
|
|
420
436
|
}
|
|
421
437
|
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
# Label operations
|
|
440
|
+
# ------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def get_labels(self, page_id: str) -> list[dict[str, Any]]:
|
|
443
|
+
"""Get labels on a page."""
|
|
444
|
+
self._call_count += 1
|
|
445
|
+
try:
|
|
446
|
+
result = self._api.get_page_labels(page_id)
|
|
447
|
+
raw = result.get("results", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
|
|
448
|
+
return [_slim_label(lbl) for lbl in raw]
|
|
449
|
+
except Exception as exc:
|
|
450
|
+
self._handle_error(exc, "get_labels")
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
def set_labels(self, page_id: str, labels: list[str]) -> list[dict[str, Any]]:
|
|
454
|
+
"""Set labels on a page (additive — does not remove existing labels)."""
|
|
455
|
+
results: list[dict[str, Any]] = []
|
|
456
|
+
for lbl in labels:
|
|
457
|
+
self._call_count += 1
|
|
458
|
+
try:
|
|
459
|
+
result = self._api.set_page_label(page_id, lbl)
|
|
460
|
+
if isinstance(result, dict) and result.get("name"):
|
|
461
|
+
results.append(_slim_label(result))
|
|
462
|
+
elif isinstance(result, list):
|
|
463
|
+
results.extend(
|
|
464
|
+
_slim_label(r) if isinstance(r, dict) and r.get("name")
|
|
465
|
+
else {"name": lbl, "prefix": "global", "id": None}
|
|
466
|
+
for r in result
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
results.append({"name": lbl, "prefix": "global", "id": None})
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
self._handle_error(exc, "set_labels")
|
|
472
|
+
return results
|
|
473
|
+
|
|
474
|
+
def remove_label(self, page_id: str, label: str) -> dict[str, Any]:
|
|
475
|
+
"""Remove a label from a page."""
|
|
476
|
+
self._call_count += 1
|
|
477
|
+
try:
|
|
478
|
+
self._api.remove_page_label(page_id, label)
|
|
479
|
+
return {"removed": True, "label": label, "page_id": page_id}
|
|
480
|
+
except Exception as exc:
|
|
481
|
+
self._handle_error(exc, "remove_label")
|
|
482
|
+
return {}
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# Comment operations
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def add_comment(self, page_id: str, body: str) -> dict[str, Any]:
|
|
489
|
+
"""Add a comment to a page."""
|
|
490
|
+
self._call_count += 1
|
|
491
|
+
try:
|
|
492
|
+
result = self._api.add_comment(page_id, body)
|
|
493
|
+
return {
|
|
494
|
+
"id": result.get("id") if isinstance(result, dict) else None,
|
|
495
|
+
"page_id": page_id,
|
|
496
|
+
"created": True,
|
|
497
|
+
}
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
self._handle_error(exc, "add_comment")
|
|
500
|
+
return {}
|
|
501
|
+
|
|
502
|
+
# ------------------------------------------------------------------
|
|
503
|
+
# Page move
|
|
504
|
+
# ------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
def move_page(
|
|
507
|
+
self,
|
|
508
|
+
space_key: str,
|
|
509
|
+
page_id: str,
|
|
510
|
+
target_title: str | None = None,
|
|
511
|
+
target_id: str | None = None,
|
|
512
|
+
position: str = "append",
|
|
513
|
+
) -> dict[str, Any]:
|
|
514
|
+
"""Move a page under a new parent."""
|
|
515
|
+
self._call_count += 1
|
|
516
|
+
try:
|
|
517
|
+
result = self._api.move_page(
|
|
518
|
+
space_key, page_id,
|
|
519
|
+
target_title=target_title,
|
|
520
|
+
target_id=target_id,
|
|
521
|
+
position=position,
|
|
522
|
+
)
|
|
523
|
+
base_url = self._config.base_url.rstrip("/") if self._config.base_url else ""
|
|
524
|
+
page_data: dict[str, Any] | None = None
|
|
525
|
+
if isinstance(result, dict):
|
|
526
|
+
# The API may return the page directly or nested under a 'page' key
|
|
527
|
+
raw_page = result.get("page", result) if "page" in result else result
|
|
528
|
+
if raw_page.get("id"):
|
|
529
|
+
page_data = _slim_page(raw_page, base_url=base_url, is_cloud=self._config.is_cloud)
|
|
530
|
+
return {
|
|
531
|
+
"moved": True,
|
|
532
|
+
"page_id": page_id,
|
|
533
|
+
"target_parent": target_title or target_id,
|
|
534
|
+
"page": page_data,
|
|
535
|
+
}
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
self._handle_error(exc, "move_page")
|
|
538
|
+
return {}
|
|
539
|
+
|
|
422
540
|
# ------------------------------------------------------------------
|
|
423
541
|
# Fingerprinting
|
|
424
542
|
# ------------------------------------------------------------------
|
|
@@ -536,6 +654,15 @@ def _slim_attachment(att: dict[str, Any]) -> dict[str, Any]:
|
|
|
536
654
|
return result
|
|
537
655
|
|
|
538
656
|
|
|
657
|
+
def _slim_label(lbl: dict[str, Any]) -> dict[str, Any]:
|
|
658
|
+
"""Extract agent-relevant fields from a raw Confluence label object."""
|
|
659
|
+
return {
|
|
660
|
+
"name": lbl.get("name", ""),
|
|
661
|
+
"prefix": lbl.get("prefix", "global"),
|
|
662
|
+
"id": lbl.get("id"),
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
539
666
|
def _slim_search_result(
|
|
540
667
|
item: dict[str, Any],
|
|
541
668
|
*,
|
|
@@ -366,6 +366,27 @@ def convert_markdown(md_text: str) -> str:
|
|
|
366
366
|
return renderer.render(tokens, {}, {})
|
|
367
367
|
|
|
368
368
|
|
|
369
|
+
def extract_h1_title(md_text: str) -> str | None:
|
|
370
|
+
"""Extract the text of the first H1 heading from Markdown source.
|
|
371
|
+
|
|
372
|
+
Returns the title string, or None if no H1 is found.
|
|
373
|
+
"""
|
|
374
|
+
parser = _create_parser()
|
|
375
|
+
tokens = parser.parse(md_text)
|
|
376
|
+
for i, token in enumerate(tokens):
|
|
377
|
+
if token.type == "heading_open" and token.tag == "h1":
|
|
378
|
+
# The next token should be an inline token with the heading content
|
|
379
|
+
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
|
380
|
+
inline = tokens[i + 1]
|
|
381
|
+
if inline.children:
|
|
382
|
+
parts: list[str] = []
|
|
383
|
+
for child in inline.children:
|
|
384
|
+
if child.type in ("text", "code_inline"):
|
|
385
|
+
parts.append(child.content)
|
|
386
|
+
return "".join(parts) if parts else None
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
369
390
|
def fingerprint_content(content: str) -> str:
|
|
370
391
|
"""Return SHA-256 hex digest of content."""
|
|
371
392
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
@@ -15,6 +15,7 @@ ERR_VALIDATION_MARKDOWN = "ERR_VALIDATION_MARKDOWN"
|
|
|
15
15
|
ERR_VALIDATION_ASSET_MISSING = "ERR_VALIDATION_ASSET_MISSING"
|
|
16
16
|
ERR_VALIDATION_SPACE_MISMATCH = "ERR_VALIDATION_SPACE_MISMATCH"
|
|
17
17
|
ERR_VALIDATION_NOT_FOUND = "ERR_VALIDATION_NOT_FOUND"
|
|
18
|
+
ERR_VALIDATION_LABEL = "ERR_VALIDATION_LABEL"
|
|
18
19
|
|
|
19
20
|
# Auth (exit 20)
|
|
20
21
|
ERR_AUTH_REQUIRED = "ERR_AUTH_REQUIRED"
|