confpub-cli 1.2.0__tar.gz → 1.3.0__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.3.0}/PKG-INFO +1 -1
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/applier.py +21 -1
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/cli.py +148 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/confluence.py +98 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/errors.py +1 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/guide.py +42 -2
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/manifest.py +12 -1
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/planner.py +4 -1
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/publish.py +20 -6
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/puller.py +15 -2
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_applier.py +85 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_confluence.py +99 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_guide.py +45 -3
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_integration.py +101 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_manifest.py +83 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_publish.py +76 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/.gitignore +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/CLAUDE.md +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/LICENSE +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/PRD.md +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/README.md +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/assets.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/config.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/converter.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/lockfile.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/output.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/py.typed +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/validator.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub.lock +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/pyproject.toml +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/__init__.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/conftest.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_config.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_converter.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_output.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_validator.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_verifier.py +0 -0
- {confpub_cli-1.2.0 → confpub_cli-1.3.0}/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.3.0
|
|
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)
|
|
@@ -38,6 +38,8 @@ auth_app = typer.Typer(help="Authentication", callback=_group_callback)
|
|
|
38
38
|
config_app = typer.Typer(help="Configuration", callback=_group_callback)
|
|
39
39
|
space_app = typer.Typer(help="Space operations", callback=_group_callback)
|
|
40
40
|
attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
|
|
41
|
+
label_app = typer.Typer(help="Label operations", callback=_group_callback)
|
|
42
|
+
comment_app = typer.Typer(help="Comment operations", callback=_group_callback)
|
|
41
43
|
|
|
42
44
|
# ---------------------------------------------------------------------------
|
|
43
45
|
# Main app
|
|
@@ -55,6 +57,8 @@ app.add_typer(auth_app, name="auth")
|
|
|
55
57
|
app.add_typer(config_app, name="config")
|
|
56
58
|
app.add_typer(space_app, name="space")
|
|
57
59
|
app.add_typer(attachment_app, name="attachment")
|
|
60
|
+
app.add_typer(label_app, name="label")
|
|
61
|
+
app.add_typer(comment_app, name="comment")
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
def _version_callback(value: bool) -> None:
|
|
@@ -239,6 +243,7 @@ def page_publish(
|
|
|
239
243
|
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
|
|
240
244
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
241
245
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
246
|
+
label: Optional[list[str]] = typer.Option(None, "--label", help="Label to apply (repeatable)"),
|
|
242
247
|
) -> None:
|
|
243
248
|
"""Publish a single Markdown file to Confluence."""
|
|
244
249
|
from confpub.publish import derive_title
|
|
@@ -262,6 +267,7 @@ def page_publish(
|
|
|
262
267
|
dry_run=dry_run,
|
|
263
268
|
backup=backup,
|
|
264
269
|
progress_callback=ctx,
|
|
270
|
+
labels=label or [],
|
|
265
271
|
)
|
|
266
272
|
ctx.result = result
|
|
267
273
|
|
|
@@ -351,6 +357,43 @@ def page_delete(
|
|
|
351
357
|
ctx.result = result
|
|
352
358
|
|
|
353
359
|
|
|
360
|
+
@page_app.command("move")
|
|
361
|
+
def page_move(
|
|
362
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
|
|
363
|
+
target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
|
|
364
|
+
space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
|
|
365
|
+
target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Move a page under a new parent."""
|
|
368
|
+
target = {"page_id": page_id}
|
|
369
|
+
with command_context("page.move", target=target) as ctx:
|
|
370
|
+
if not target_parent and not target_parent_id:
|
|
371
|
+
raise ConfpubError(
|
|
372
|
+
"ERR_VALIDATION_REQUIRED",
|
|
373
|
+
"Either --target-parent or --target-parent-id is required",
|
|
374
|
+
)
|
|
375
|
+
if target_parent and not space:
|
|
376
|
+
raise ConfpubError(
|
|
377
|
+
"ERR_VALIDATION_REQUIRED",
|
|
378
|
+
"--space is required when using --target-parent",
|
|
379
|
+
)
|
|
380
|
+
from confpub.confluence import build_client
|
|
381
|
+
client = build_client()
|
|
382
|
+
|
|
383
|
+
if target_parent_id:
|
|
384
|
+
# Use target_id directly — more reliable, no title resolution needed
|
|
385
|
+
parent_page = client.get_page_by_id(target_parent_id)
|
|
386
|
+
if not parent_page or not parent_page.get("id"):
|
|
387
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
388
|
+
raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Target parent page not found: {target_parent_id}")
|
|
389
|
+
resolved_space = parent_page.get("space", {}).get("key", space or "")
|
|
390
|
+
result = client.move_page(resolved_space, page_id, target_id=target_parent_id)
|
|
391
|
+
else:
|
|
392
|
+
result = client.move_page(space, page_id, target_title=target_parent)
|
|
393
|
+
|
|
394
|
+
ctx.result = result
|
|
395
|
+
|
|
396
|
+
|
|
354
397
|
@space_app.command("list")
|
|
355
398
|
def space_list() -> None:
|
|
356
399
|
"""List accessible Confluence spaces."""
|
|
@@ -481,6 +524,111 @@ def config_inspect() -> None:
|
|
|
481
524
|
ctx.result = config.to_display_dict()
|
|
482
525
|
|
|
483
526
|
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# Label commands
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@label_app.command("list")
|
|
533
|
+
def label_list(
|
|
534
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
535
|
+
) -> None:
|
|
536
|
+
"""List labels on a Confluence page."""
|
|
537
|
+
with command_context("label.list", target={"page_id": page_id}) as ctx:
|
|
538
|
+
from confpub.confluence import build_client
|
|
539
|
+
client = build_client()
|
|
540
|
+
labels = client.get_labels(page_id)
|
|
541
|
+
ctx.result = {"labels": labels, "count": len(labels)}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@label_app.command("add")
|
|
545
|
+
def label_add(
|
|
546
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
547
|
+
label: list[str] = typer.Option(..., "--label", help="Label name (repeatable)"),
|
|
548
|
+
) -> None:
|
|
549
|
+
"""Add labels to a Confluence page."""
|
|
550
|
+
with command_context("label.add", target={"page_id": page_id}) as ctx:
|
|
551
|
+
from confpub.errors import ERR_VALIDATION_LABEL
|
|
552
|
+
# Validate labels
|
|
553
|
+
for lbl in label:
|
|
554
|
+
if " " in lbl:
|
|
555
|
+
raise ConfpubError(
|
|
556
|
+
ERR_VALIDATION_LABEL,
|
|
557
|
+
f"Label must not contain spaces: '{lbl}'",
|
|
558
|
+
details={"label": lbl},
|
|
559
|
+
)
|
|
560
|
+
if len(lbl) > 255:
|
|
561
|
+
raise ConfpubError(
|
|
562
|
+
ERR_VALIDATION_LABEL,
|
|
563
|
+
f"Label exceeds 255 characters: '{lbl[:50]}...'",
|
|
564
|
+
details={"label": lbl, "length": len(lbl)},
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
from confpub.confluence import build_client
|
|
568
|
+
client = build_client()
|
|
569
|
+
results = client.set_labels(page_id, label)
|
|
570
|
+
ctx.result = {"labels_added": label, "results": results}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@label_app.command("remove")
|
|
574
|
+
def label_remove(
|
|
575
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
576
|
+
label: list[str] = typer.Option(..., "--label", help="Label name to remove (repeatable)"),
|
|
577
|
+
) -> None:
|
|
578
|
+
"""Remove labels from a Confluence page."""
|
|
579
|
+
with command_context("label.remove", target={"page_id": page_id}) as ctx:
|
|
580
|
+
from confpub.confluence import build_client
|
|
581
|
+
client = build_client()
|
|
582
|
+
results = []
|
|
583
|
+
for lbl in label:
|
|
584
|
+
result = client.remove_label(page_id, lbl)
|
|
585
|
+
results.append(result)
|
|
586
|
+
ctx.result = {"labels_removed": label, "results": results}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
# Comment commands
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@comment_app.command("add")
|
|
595
|
+
def comment_add(
|
|
596
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
597
|
+
text: Optional[str] = typer.Option(None, "--text", help="Comment text (Markdown)"),
|
|
598
|
+
file: Optional[str] = typer.Option(None, "--file", help="Path to Markdown file for comment body"),
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Add a comment to a Confluence page."""
|
|
601
|
+
with command_context("comment.add", target={"page_id": page_id}) as ctx:
|
|
602
|
+
if not text and not file:
|
|
603
|
+
raise ConfpubError(
|
|
604
|
+
"ERR_VALIDATION_REQUIRED",
|
|
605
|
+
"Either --text or --file is required",
|
|
606
|
+
)
|
|
607
|
+
if text and file:
|
|
608
|
+
raise ConfpubError(
|
|
609
|
+
"ERR_VALIDATION_REQUIRED",
|
|
610
|
+
"--text and --file are mutually exclusive",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
if file:
|
|
614
|
+
from pathlib import Path
|
|
615
|
+
p = Path(file)
|
|
616
|
+
if not p.exists():
|
|
617
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
618
|
+
raise ConfpubError(ERR_IO_FILE_NOT_FOUND, f"File not found: {file}")
|
|
619
|
+
md_text = p.read_text(encoding="utf-8")
|
|
620
|
+
else:
|
|
621
|
+
md_text = text
|
|
622
|
+
|
|
623
|
+
from confpub.converter import convert_markdown
|
|
624
|
+
storage_body = convert_markdown(md_text)
|
|
625
|
+
|
|
626
|
+
from confpub.confluence import build_client
|
|
627
|
+
client = build_client()
|
|
628
|
+
result = client.add_comment(page_id, storage_body)
|
|
629
|
+
ctx.result = result
|
|
630
|
+
|
|
631
|
+
|
|
484
632
|
# ---------------------------------------------------------------------------
|
|
485
633
|
# search command (top-level, not in a subgroup)
|
|
486
634
|
# ---------------------------------------------------------------------------
|
|
@@ -419,6 +419,95 @@ class ConfluenceClient:
|
|
|
419
419
|
"has_more": (api_start + api_limit) < total,
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
# Label operations
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def get_labels(self, page_id: str) -> list[dict[str, Any]]:
|
|
427
|
+
"""Get labels on a page."""
|
|
428
|
+
self._call_count += 1
|
|
429
|
+
try:
|
|
430
|
+
result = self._api.get_page_labels(page_id)
|
|
431
|
+
raw = result.get("results", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
|
|
432
|
+
return [_slim_label(lbl) for lbl in raw]
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
self._handle_error(exc, "get_labels")
|
|
435
|
+
return []
|
|
436
|
+
|
|
437
|
+
def set_labels(self, page_id: str, labels: list[str]) -> list[dict[str, Any]]:
|
|
438
|
+
"""Set labels on a page (additive — does not remove existing labels)."""
|
|
439
|
+
results: list[dict[str, Any]] = []
|
|
440
|
+
for lbl in labels:
|
|
441
|
+
self._call_count += 1
|
|
442
|
+
try:
|
|
443
|
+
result = self._api.set_page_label(page_id, lbl)
|
|
444
|
+
if isinstance(result, dict):
|
|
445
|
+
results.append(_slim_label(result))
|
|
446
|
+
elif isinstance(result, list):
|
|
447
|
+
results.extend(_slim_label(r) for r in result)
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
self._handle_error(exc, "set_labels")
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
def remove_label(self, page_id: str, label: str) -> dict[str, Any]:
|
|
453
|
+
"""Remove a label from a page."""
|
|
454
|
+
self._call_count += 1
|
|
455
|
+
try:
|
|
456
|
+
self._api.remove_page_label(page_id, label)
|
|
457
|
+
return {"removed": True, "label": label, "page_id": page_id}
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
self._handle_error(exc, "remove_label")
|
|
460
|
+
return {}
|
|
461
|
+
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
# Comment operations
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def add_comment(self, page_id: str, body: str) -> dict[str, Any]:
|
|
467
|
+
"""Add a comment to a page."""
|
|
468
|
+
self._call_count += 1
|
|
469
|
+
try:
|
|
470
|
+
result = self._api.add_comment(page_id, body)
|
|
471
|
+
return {
|
|
472
|
+
"id": result.get("id") if isinstance(result, dict) else None,
|
|
473
|
+
"page_id": page_id,
|
|
474
|
+
"created": True,
|
|
475
|
+
}
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
self._handle_error(exc, "add_comment")
|
|
478
|
+
return {}
|
|
479
|
+
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
# Page move
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def move_page(
|
|
485
|
+
self,
|
|
486
|
+
space_key: str,
|
|
487
|
+
page_id: str,
|
|
488
|
+
target_title: str | None = None,
|
|
489
|
+
target_id: str | None = None,
|
|
490
|
+
position: str = "append",
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""Move a page under a new parent."""
|
|
493
|
+
self._call_count += 1
|
|
494
|
+
try:
|
|
495
|
+
result = self._api.move_page(
|
|
496
|
+
space_key, page_id,
|
|
497
|
+
target_title=target_title,
|
|
498
|
+
target_id=target_id,
|
|
499
|
+
position=position,
|
|
500
|
+
)
|
|
501
|
+
return {
|
|
502
|
+
"moved": True,
|
|
503
|
+
"page_id": page_id,
|
|
504
|
+
"target_parent": target_title or target_id,
|
|
505
|
+
"result": result if isinstance(result, dict) else {"raw": str(result)},
|
|
506
|
+
}
|
|
507
|
+
except Exception as exc:
|
|
508
|
+
self._handle_error(exc, "move_page")
|
|
509
|
+
return {}
|
|
510
|
+
|
|
422
511
|
# ------------------------------------------------------------------
|
|
423
512
|
# Fingerprinting
|
|
424
513
|
# ------------------------------------------------------------------
|
|
@@ -536,6 +625,15 @@ def _slim_attachment(att: dict[str, Any]) -> dict[str, Any]:
|
|
|
536
625
|
return result
|
|
537
626
|
|
|
538
627
|
|
|
628
|
+
def _slim_label(lbl: dict[str, Any]) -> dict[str, Any]:
|
|
629
|
+
"""Extract agent-relevant fields from a raw Confluence label object."""
|
|
630
|
+
return {
|
|
631
|
+
"name": lbl.get("name", ""),
|
|
632
|
+
"prefix": lbl.get("prefix", "global"),
|
|
633
|
+
"id": lbl.get("id"),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
539
637
|
def _slim_search_result(
|
|
540
638
|
item: dict[str, Any],
|
|
541
639
|
*,
|
|
@@ -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"
|
|
@@ -24,6 +24,7 @@ from confpub.errors import (
|
|
|
24
24
|
ERR_IO_FILE_NOT_FOUND,
|
|
25
25
|
ERR_IO_TIMEOUT,
|
|
26
26
|
ERR_VALIDATION_ASSET_MISSING,
|
|
27
|
+
ERR_VALIDATION_LABEL,
|
|
27
28
|
ERR_VALIDATION_MANIFEST,
|
|
28
29
|
ERR_VALIDATION_MARKDOWN,
|
|
29
30
|
ERR_VALIDATION_NOT_FOUND,
|
|
@@ -121,11 +122,23 @@ def build_guide() -> dict[str, Any]:
|
|
|
121
122
|
"group": "write",
|
|
122
123
|
"mutates": True,
|
|
123
124
|
"description": "Publish a single Markdown file to Confluence",
|
|
124
|
-
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
|
|
125
|
+
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup", "--label"],
|
|
125
126
|
"agent_hint": (
|
|
126
127
|
"When --title is omitted, the title is inferred from the filename: "
|
|
127
128
|
"the stem is extracted, hyphens and underscores are replaced with spaces, "
|
|
128
|
-
"and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'."
|
|
129
|
+
"and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
|
|
130
|
+
"Use --label to apply labels (repeatable): --label api --label docs."
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
"page.move": {
|
|
134
|
+
"group": "write",
|
|
135
|
+
"mutates": True,
|
|
136
|
+
"description": "Move a page under a new parent",
|
|
137
|
+
"flags": ["--page-id", "--target-parent", "--space", "--target-parent-id"],
|
|
138
|
+
"agent_hint": (
|
|
139
|
+
"Use --target-parent + --space for title-based targeting, "
|
|
140
|
+
"or --target-parent-id for ID-based targeting. "
|
|
141
|
+
"The page ID does not change after a move."
|
|
129
142
|
),
|
|
130
143
|
},
|
|
131
144
|
"page.pull": {
|
|
@@ -177,6 +190,32 @@ def build_guide() -> dict[str, Any]:
|
|
|
177
190
|
"description": "Upload an attachment to a Confluence page",
|
|
178
191
|
"flags": ["--page-id"],
|
|
179
192
|
},
|
|
193
|
+
"label.list": {
|
|
194
|
+
"group": "read",
|
|
195
|
+
"mutates": False,
|
|
196
|
+
"description": "List labels on a Confluence page",
|
|
197
|
+
"flags": ["--page-id"],
|
|
198
|
+
},
|
|
199
|
+
"label.add": {
|
|
200
|
+
"group": "write",
|
|
201
|
+
"mutates": True,
|
|
202
|
+
"description": "Add labels to a Confluence page",
|
|
203
|
+
"flags": ["--page-id", "--label"],
|
|
204
|
+
"agent_hint": "Use --label for each label (repeatable): --label api --label docs. Labels must not contain spaces and max 255 characters.",
|
|
205
|
+
},
|
|
206
|
+
"label.remove": {
|
|
207
|
+
"group": "write",
|
|
208
|
+
"mutates": True,
|
|
209
|
+
"description": "Remove labels from a Confluence page",
|
|
210
|
+
"flags": ["--page-id", "--label"],
|
|
211
|
+
},
|
|
212
|
+
"comment.add": {
|
|
213
|
+
"group": "write",
|
|
214
|
+
"mutates": True,
|
|
215
|
+
"description": "Add a comment to a Confluence page",
|
|
216
|
+
"flags": ["--page-id", "--text", "--file"],
|
|
217
|
+
"agent_hint": "Exactly one of --text or --file is required. The body is converted from Markdown to Confluence storage format.",
|
|
218
|
+
},
|
|
180
219
|
"plan.create": {
|
|
181
220
|
"group": "transactional",
|
|
182
221
|
"mutates": False,
|
|
@@ -254,6 +293,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
254
293
|
ERR_VALIDATION_ASSET_MISSING: _error_code_entry(ERR_VALIDATION_ASSET_MISSING),
|
|
255
294
|
ERR_VALIDATION_NOT_FOUND: _error_code_entry(ERR_VALIDATION_NOT_FOUND),
|
|
256
295
|
ERR_VALIDATION_SPACE_MISMATCH: _error_code_entry(ERR_VALIDATION_SPACE_MISMATCH),
|
|
296
|
+
ERR_VALIDATION_LABEL: _error_code_entry(ERR_VALIDATION_LABEL),
|
|
257
297
|
ERR_AUTH_REQUIRED: _error_code_entry(ERR_AUTH_REQUIRED),
|
|
258
298
|
ERR_AUTH_EXPIRED: _error_code_entry(ERR_AUTH_EXPIRED),
|
|
259
299
|
ERR_AUTH_FORBIDDEN: _error_code_entry(ERR_AUTH_FORBIDDEN),
|
|
@@ -51,6 +51,7 @@ class ManifestPage(BaseModel):
|
|
|
51
51
|
title: str
|
|
52
52
|
file: str
|
|
53
53
|
assets: list[str] = Field(default_factory=list)
|
|
54
|
+
labels: list[str] = Field(default_factory=list)
|
|
54
55
|
children: list[ManifestPage] = Field(default_factory=list)
|
|
55
56
|
|
|
56
57
|
|
|
@@ -93,6 +94,7 @@ class PlanPage(BaseModel):
|
|
|
93
94
|
operation: Literal["create", "update", "noop"]
|
|
94
95
|
parent_title: Optional[str] = None
|
|
95
96
|
attachments: list[PlanAttachment] = Field(default_factory=list)
|
|
97
|
+
labels: list[str] = Field(default_factory=list)
|
|
96
98
|
|
|
97
99
|
|
|
98
100
|
class PlanSummary(BaseModel):
|
|
@@ -102,6 +104,7 @@ class PlanSummary(BaseModel):
|
|
|
102
104
|
update: int = 0
|
|
103
105
|
noop: int = 0
|
|
104
106
|
attachments_to_upload: int = 0
|
|
107
|
+
labels_to_apply: int = 0
|
|
105
108
|
|
|
106
109
|
|
|
107
110
|
class PlanArtifact(BaseModel):
|
|
@@ -126,6 +129,7 @@ class FlatPage(BaseModel):
|
|
|
126
129
|
title: str
|
|
127
130
|
file: str
|
|
128
131
|
assets: list[str] = Field(default_factory=list)
|
|
132
|
+
labels: list[str] = Field(default_factory=list) # merged global + per-page
|
|
129
133
|
parent_title: str # The parent page title (from manifest parent or parent page)
|
|
130
134
|
|
|
131
135
|
|
|
@@ -187,6 +191,9 @@ def generate_manifest_yaml(
|
|
|
187
191
|
result = []
|
|
188
192
|
for p in pages:
|
|
189
193
|
entry: dict[str, Any] = {"title": p["title"], "file": p["file"]}
|
|
194
|
+
labels = p.get("labels", [])
|
|
195
|
+
if labels:
|
|
196
|
+
entry["labels"] = labels
|
|
190
197
|
children = p.get("children", [])
|
|
191
198
|
if children:
|
|
192
199
|
entry["children"] = _build_pages(children)
|
|
@@ -206,16 +213,20 @@ def resolve_page_tree(manifest: Manifest) -> list[FlatPage]:
|
|
|
206
213
|
"""Flatten the recursive page tree into a list with parent references.
|
|
207
214
|
|
|
208
215
|
Pages are returned in tree order (parents before children) to ensure
|
|
209
|
-
correct creation order.
|
|
216
|
+
correct creation order. Labels are merged: global (manifest.labels)
|
|
217
|
+
first, then per-page, deduplicated while preserving order.
|
|
210
218
|
"""
|
|
211
219
|
flat: list[FlatPage] = []
|
|
220
|
+
global_labels = manifest.labels
|
|
212
221
|
|
|
213
222
|
def _walk(pages: list[ManifestPage], parent_title: str) -> None:
|
|
214
223
|
for page in pages:
|
|
224
|
+
merged = list(dict.fromkeys(global_labels + page.labels))
|
|
215
225
|
flat.append(FlatPage(
|
|
216
226
|
title=page.title,
|
|
217
227
|
file=page.file,
|
|
218
228
|
assets=page.assets,
|
|
229
|
+
labels=merged,
|
|
219
230
|
parent_title=parent_title,
|
|
220
231
|
))
|
|
221
232
|
if page.children:
|
|
@@ -58,7 +58,7 @@ def create_plan(
|
|
|
58
58
|
|
|
59
59
|
# Build plan pages
|
|
60
60
|
plan_pages: list[PlanPage] = []
|
|
61
|
-
counts = {"create": 0, "update": 0, "noop": 0, "attachments_to_upload": 0}
|
|
61
|
+
counts = {"create": 0, "update": 0, "noop": 0, "attachments_to_upload": 0, "labels_to_apply": 0}
|
|
62
62
|
|
|
63
63
|
for i, fp in enumerate(flat_pages):
|
|
64
64
|
plan_id = f"plan_{i + 1}"
|
|
@@ -118,6 +118,8 @@ def create_plan(
|
|
|
118
118
|
|
|
119
119
|
counts[operation] += 1
|
|
120
120
|
counts["attachments_to_upload"] += len(plan_attachments)
|
|
121
|
+
if fp.labels:
|
|
122
|
+
counts["labels_to_apply"] += len(fp.labels)
|
|
121
123
|
|
|
122
124
|
plan_pages.append(PlanPage(
|
|
123
125
|
id=plan_id,
|
|
@@ -128,6 +130,7 @@ def create_plan(
|
|
|
128
130
|
operation=operation,
|
|
129
131
|
parent_title=fp.parent_title,
|
|
130
132
|
attachments=plan_attachments,
|
|
133
|
+
labels=fp.labels,
|
|
131
134
|
))
|
|
132
135
|
|
|
133
136
|
# Build plan artifact
|
|
@@ -39,6 +39,7 @@ def publish_page(
|
|
|
39
39
|
dry_run: bool = False,
|
|
40
40
|
backup: bool = False,
|
|
41
41
|
progress_callback: Any = None,
|
|
42
|
+
labels: list[str] | None = None,
|
|
42
43
|
) -> dict[str, Any]:
|
|
43
44
|
"""Publish a single Markdown file to Confluence.
|
|
44
45
|
|
|
@@ -129,6 +130,8 @@ def publish_page(
|
|
|
129
130
|
}
|
|
130
131
|
if assets:
|
|
131
132
|
change["attachments_to_upload"] = [a.source_path for a in assets]
|
|
133
|
+
if labels:
|
|
134
|
+
change["labels_to_apply"] = labels
|
|
132
135
|
|
|
133
136
|
warnings: list[str] = []
|
|
134
137
|
parent_page = client.get_page(space, parent)
|
|
@@ -146,14 +149,19 @@ def publish_page(
|
|
|
146
149
|
|
|
147
150
|
# Real publish
|
|
148
151
|
if operation == "noop":
|
|
152
|
+
noop_change: dict[str, Any] = {
|
|
153
|
+
"type": "page.noop",
|
|
154
|
+
"title": page_title,
|
|
155
|
+
"confluence_page_id": existing_page_id,
|
|
156
|
+
"before": {"version": current_version} if current_version else None,
|
|
157
|
+
}
|
|
158
|
+
# Labels may still need applying even when content is unchanged
|
|
159
|
+
if labels and existing_page_id:
|
|
160
|
+
client.set_labels(existing_page_id, labels)
|
|
161
|
+
noop_change["labels_added"] = labels
|
|
149
162
|
return {
|
|
150
163
|
"dry_run": False,
|
|
151
|
-
"changes": [
|
|
152
|
-
"type": "page.noop",
|
|
153
|
-
"title": page_title,
|
|
154
|
-
"confluence_page_id": existing_page_id,
|
|
155
|
-
"before": {"version": current_version} if current_version else None,
|
|
156
|
-
}],
|
|
164
|
+
"changes": [noop_change],
|
|
157
165
|
"summary": {"noop": 1},
|
|
158
166
|
}
|
|
159
167
|
|
|
@@ -192,6 +200,10 @@ def publish_page(
|
|
|
192
200
|
client.update_page(page_id, page_title, storage)
|
|
193
201
|
uploaded_attachments = [a.source_path for a in assets]
|
|
194
202
|
|
|
203
|
+
# Apply labels
|
|
204
|
+
if labels:
|
|
205
|
+
client.set_labels(page_id, labels)
|
|
206
|
+
|
|
195
207
|
# Update lockfile
|
|
196
208
|
new_version_int = new_version if isinstance(new_version, int) else 1
|
|
197
209
|
update_lockfile(lockfile, page_title, page_id, new_version_int, content_fingerprint=local_fingerprint)
|
|
@@ -215,6 +227,8 @@ def publish_page(
|
|
|
215
227
|
change["backup_path"] = backup_file_path
|
|
216
228
|
if uploaded_attachments:
|
|
217
229
|
change["attachments_added"] = uploaded_attachments
|
|
230
|
+
if labels:
|
|
231
|
+
change["labels_added"] = labels
|
|
218
232
|
|
|
219
233
|
return {
|
|
220
234
|
"dry_run": False,
|
|
@@ -187,10 +187,12 @@ def _build_page_tree(
|
|
|
187
187
|
file_paths: dict[str, str],
|
|
188
188
|
root_page_id: str,
|
|
189
189
|
output_dir: str = ".",
|
|
190
|
+
page_labels: dict[str, list[str]] | None = None,
|
|
190
191
|
) -> list[dict[str, Any]]:
|
|
191
192
|
"""Build a hierarchical page tree for manifest generation."""
|
|
192
193
|
id_to_entry: dict[str, dict[str, Any]] = {}
|
|
193
194
|
children_map: dict[str | None, list[str]] = {}
|
|
195
|
+
labels_map = page_labels or {}
|
|
194
196
|
|
|
195
197
|
for entry in pages:
|
|
196
198
|
page = entry["page"]
|
|
@@ -198,11 +200,14 @@ def _build_page_tree(
|
|
|
198
200
|
parent_id = str(entry["parent_id"]) if entry["parent_id"] else None
|
|
199
201
|
file_path = file_paths.get(pid, "")
|
|
200
202
|
|
|
201
|
-
|
|
203
|
+
node: dict[str, Any] = {
|
|
202
204
|
"title": page.get("title", ""),
|
|
203
205
|
"file": os.path.relpath(file_path, output_dir) if file_path else "",
|
|
204
206
|
"children": [],
|
|
205
207
|
}
|
|
208
|
+
if labels_map.get(pid):
|
|
209
|
+
node["labels"] = labels_map[pid]
|
|
210
|
+
id_to_entry[pid] = node
|
|
206
211
|
children_map.setdefault(parent_id, []).append(pid)
|
|
207
212
|
|
|
208
213
|
def _attach_children(parent_id: str) -> list[dict[str, Any]]:
|
|
@@ -300,12 +305,16 @@ def pull_pages(
|
|
|
300
305
|
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
|
301
306
|
Path(out_path).write_text(result.markdown, encoding="utf-8")
|
|
302
307
|
|
|
308
|
+
# Fetch labels
|
|
309
|
+
page_labels = [lbl["name"] for lbl in client.get_labels(pid)]
|
|
310
|
+
|
|
303
311
|
files_result.append({
|
|
304
312
|
"page_id": pid,
|
|
305
313
|
"title": page_title,
|
|
306
314
|
"file": out_path,
|
|
307
315
|
"version": version_num,
|
|
308
316
|
"attachments_downloaded": attachments_downloaded,
|
|
317
|
+
"labels": page_labels,
|
|
309
318
|
})
|
|
310
319
|
|
|
311
320
|
# Generate manifest if requested or recursive with multiple pages
|
|
@@ -315,7 +324,11 @@ def pull_pages(
|
|
|
315
324
|
# Determine the actual parent of the root page
|
|
316
325
|
ancestors = client.get_page_ancestors(root_id)
|
|
317
326
|
manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
|
|
318
|
-
|
|
327
|
+
# Collect labels by page ID for manifest generation
|
|
328
|
+
pulled_labels: dict[str, list[str]] = {
|
|
329
|
+
f["page_id"]: f.get("labels", []) for f in files_result
|
|
330
|
+
}
|
|
331
|
+
page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir, page_labels=pulled_labels)
|
|
319
332
|
manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
|
|
320
333
|
manifest_path = os.path.join(output_dir, "confpub.yaml")
|
|
321
334
|
Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
|