confpub-cli 1.7.4__tar.gz → 1.7.6__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.7.4 → confpub_cli-1.7.6}/PKG-INFO +1 -1
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/__init__.py +1 -1
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/cli.py +66 -12
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/confluence.py +8 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/front_matter.py +18 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/guide.py +7 -3
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/puller.py +27 -50
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_puller.py +14 -10
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/.github/copilot-instructions.md +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/.gitignore +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/CLAUDE.md +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/LICENSE +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/PRD.md +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/README.md +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/applier.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/assets.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/config.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/converter.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/envelope.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/errors.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/lockfile.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/macro_plugin.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/manifest.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/output.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/planner.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/publish.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/py.typed +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/validator.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub/verifier.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/confpub.lock +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/pyproject.toml +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/__init__.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/conftest.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_applier.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_assets.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_config.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_confluence.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_converter.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_envelope.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_errors.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_front_matter.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_guide.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_integration.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_macro_plugin.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_manifest.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_output.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_planner.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_publish.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_validator.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/tests/test_verifier.py +0 -0
- {confpub_cli-1.7.4 → confpub_cli-1.7.6}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.6
|
|
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
|
|
@@ -217,6 +217,8 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
217
217
|
@page_app.command("list")
|
|
218
218
|
def page_list(
|
|
219
219
|
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
220
|
+
title: Optional[str] = typer.Option(None, "--title", help="Filter by title (substring match)"),
|
|
221
|
+
label: Optional[str] = typer.Option(None, "--label", help="Filter by label"),
|
|
220
222
|
limit: int = typer.Option(25, "--limit", help="Maximum number of pages to return"),
|
|
221
223
|
start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
|
|
222
224
|
) -> None:
|
|
@@ -227,14 +229,38 @@ def page_list(
|
|
|
227
229
|
from confpub.confluence import build_client, _slim_page
|
|
228
230
|
client = build_client()
|
|
229
231
|
ctx.client = client
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
"
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
232
|
+
|
|
233
|
+
if label:
|
|
234
|
+
# Use CQL search for label filtering — the list_pages API doesn't support it
|
|
235
|
+
cql = f'type = page AND space = "{space}" AND label = "{label}"'
|
|
236
|
+
if title:
|
|
237
|
+
cql += f' AND title ~ "{title}"'
|
|
238
|
+
search_result = client.search(cql, start=start, limit=limit)
|
|
239
|
+
pages = []
|
|
240
|
+
for sr in search_result.get("results", []):
|
|
241
|
+
page_id = sr.get("id")
|
|
242
|
+
if page_id:
|
|
243
|
+
pages.append(sr)
|
|
244
|
+
ctx.result = {
|
|
245
|
+
"pages": pages,
|
|
246
|
+
"start": search_result.get("start", start),
|
|
247
|
+
"limit": search_result.get("limit", limit),
|
|
248
|
+
"size": len(pages),
|
|
249
|
+
"has_more": search_result.get("has_more", False),
|
|
250
|
+
}
|
|
251
|
+
else:
|
|
252
|
+
page_result = client.list_pages(space, start=start, limit=limit)
|
|
253
|
+
pages = [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in page_result["pages"]]
|
|
254
|
+
if title:
|
|
255
|
+
title_lower = title.lower()
|
|
256
|
+
pages = [p for p in pages if title_lower in (p.get("title") or "").lower()]
|
|
257
|
+
ctx.result = {
|
|
258
|
+
"pages": pages,
|
|
259
|
+
"start": page_result["start"],
|
|
260
|
+
"limit": page_result["limit"],
|
|
261
|
+
"size": len(pages),
|
|
262
|
+
"has_more": page_result["has_more"],
|
|
263
|
+
}
|
|
238
264
|
|
|
239
265
|
|
|
240
266
|
@page_app.command("inspect")
|
|
@@ -265,6 +291,8 @@ def page_inspect(
|
|
|
265
291
|
ctx.result = page
|
|
266
292
|
else:
|
|
267
293
|
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud)
|
|
294
|
+
labels = client.get_labels(str(page["id"]))
|
|
295
|
+
result["labels"] = labels
|
|
268
296
|
if format == "markdown" and "body_storage" in result:
|
|
269
297
|
from confpub.reverse_converter import convert_storage_to_markdown
|
|
270
298
|
conversion = convert_storage_to_markdown(result["body_storage"])
|
|
@@ -316,6 +344,15 @@ def page_publish(
|
|
|
316
344
|
if effective_page_id:
|
|
317
345
|
target["page_id"] = effective_page_id
|
|
318
346
|
with command_context("page.publish", target=target) as ctx:
|
|
347
|
+
if not source.exists():
|
|
348
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
349
|
+
raise ConfpubError(
|
|
350
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
351
|
+
f"Source file not found: {file}",
|
|
352
|
+
details={"file": file},
|
|
353
|
+
retryable=False,
|
|
354
|
+
suggested_action="fix_input",
|
|
355
|
+
)
|
|
319
356
|
space = _resolve_space(space, required=True, fm_space=fm_space)
|
|
320
357
|
ctx.target["space"] = space
|
|
321
358
|
|
|
@@ -502,10 +539,21 @@ def attachment_upload(
|
|
|
502
539
|
) -> None:
|
|
503
540
|
"""Upload an attachment to a Confluence page."""
|
|
504
541
|
with command_context("attachment.upload", target={"page_id": page_id, "file": file}) as ctx:
|
|
542
|
+
from pathlib import Path as _Path
|
|
543
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
544
|
+
source = _Path(file).resolve()
|
|
545
|
+
if not source.exists():
|
|
546
|
+
raise ConfpubError(
|
|
547
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
548
|
+
f"File not found: {file}",
|
|
549
|
+
details={"file": file},
|
|
550
|
+
retryable=False,
|
|
551
|
+
suggested_action="fix_input",
|
|
552
|
+
)
|
|
505
553
|
from confpub.confluence import build_client
|
|
506
554
|
client = build_client()
|
|
507
555
|
ctx.client = client
|
|
508
|
-
result = client.upload_attachment(page_id,
|
|
556
|
+
result = client.upload_attachment(page_id, str(source))
|
|
509
557
|
ctx.result = result
|
|
510
558
|
|
|
511
559
|
|
|
@@ -764,9 +812,15 @@ def search(
|
|
|
764
812
|
)
|
|
765
813
|
result["cql_query"] = effective_cql
|
|
766
814
|
if space and result.get("total", 0) == 0:
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
815
|
+
try:
|
|
816
|
+
spaces = client.list_spaces()
|
|
817
|
+
known_keys = {s["key"] for s in spaces}
|
|
818
|
+
if space not in known_keys:
|
|
819
|
+
ctx.warnings.append(f"Space '{space}' not found. Use 'space list' to see accessible spaces.")
|
|
820
|
+
else:
|
|
821
|
+
ctx.warnings.append(f"No results found in space '{space}'.")
|
|
822
|
+
except Exception:
|
|
823
|
+
ctx.warnings.append(f"No results found. Verify space '{space}' exists (use 'space list' to check).")
|
|
770
824
|
ctx.result = result
|
|
771
825
|
|
|
772
826
|
|
|
@@ -62,6 +62,14 @@ class ConfluenceClient:
|
|
|
62
62
|
def _handle_error(self, exc: Exception, context: str = "") -> None:
|
|
63
63
|
"""Translate atlassian-python-api exceptions to ConfpubError."""
|
|
64
64
|
msg = str(exc)
|
|
65
|
+
if isinstance(exc, (FileNotFoundError, OSError)) and not isinstance(exc, ConnectionError):
|
|
66
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
67
|
+
raise ConfpubError(
|
|
68
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
69
|
+
f"File error ({context}): {msg}",
|
|
70
|
+
retryable=False,
|
|
71
|
+
suggested_action="fix_input",
|
|
72
|
+
) from exc
|
|
65
73
|
if "401" in msg or "Unauthorized" in msg:
|
|
66
74
|
raise ConfpubError(
|
|
67
75
|
ERR_AUTH_FORBIDDEN,
|
|
@@ -10,6 +10,8 @@ import dataclasses
|
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
13
15
|
from confpub.converter import extract_front_matter
|
|
14
16
|
from confpub.errors import ERR_VALIDATION_MARKDOWN, ConfpubError
|
|
15
17
|
|
|
@@ -24,6 +26,22 @@ class FrontMatterData:
|
|
|
24
26
|
labels: list[str] = field(default_factory=list)
|
|
25
27
|
page_id: str | None = None
|
|
26
28
|
|
|
29
|
+
def to_yaml_block(self) -> str:
|
|
30
|
+
"""Serialize to a YAML front matter block (``--- ... ---``)."""
|
|
31
|
+
data: dict[str, Any] = {}
|
|
32
|
+
if self.title is not None:
|
|
33
|
+
data["title"] = self.title
|
|
34
|
+
if self.page_id is not None:
|
|
35
|
+
data["page_id"] = self.page_id
|
|
36
|
+
if self.space is not None:
|
|
37
|
+
data["space"] = self.space
|
|
38
|
+
if self.parent is not None:
|
|
39
|
+
data["parent"] = self.parent
|
|
40
|
+
if self.labels:
|
|
41
|
+
data["labels"] = self.labels
|
|
42
|
+
yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
43
|
+
return f"---\n{yaml_str}---\n\n"
|
|
44
|
+
|
|
27
45
|
|
|
28
46
|
def _validate_string(raw: dict[str, Any], key: str) -> str | None:
|
|
29
47
|
"""Extract and validate a string field, or None if absent."""
|
|
@@ -93,7 +93,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
93
93
|
"group": "read",
|
|
94
94
|
"mutates": False,
|
|
95
95
|
"description": "List pages in a Confluence space",
|
|
96
|
-
"flags": ["--space", "--limit", "--start"],
|
|
96
|
+
"flags": ["--space", "--title", "--label", "--limit", "--start"],
|
|
97
97
|
"result_schema": {
|
|
98
98
|
"pages": "list of slim page objects",
|
|
99
99
|
"start": "int — current offset",
|
|
@@ -104,6 +104,8 @@ def build_guide() -> dict[str, Any]:
|
|
|
104
104
|
"agent_hint": (
|
|
105
105
|
"Use --start and --limit for pagination: first call with --start 0 --limit 25, "
|
|
106
106
|
"then if has_more is true, call again with --start 25 --limit 25, and so on. "
|
|
107
|
+
"Use --title for client-side substring filtering on page titles. "
|
|
108
|
+
"Use --label to filter pages by label (uses CQL search). "
|
|
107
109
|
"For personal spaces, quote the tilde: --space '~username' "
|
|
108
110
|
"(PowerShell expands unquoted ~). Or set CONFPUB_SPACE env var."
|
|
109
111
|
),
|
|
@@ -115,7 +117,8 @@ def build_guide() -> dict[str, Any]:
|
|
|
115
117
|
"flags": ["--space", "--title", "--page-id", "--format", "--raw"],
|
|
116
118
|
"agent_hint": (
|
|
117
119
|
"Use --format markdown to get the page body as Markdown instead of Confluence storage format. "
|
|
118
|
-
"Use --raw for the full unprocessed API
|
|
120
|
+
"Use --raw for the full unprocessed Confluence REST API v2 page response "
|
|
121
|
+
"(includes extensions, metadata, restrictions, version history — useful for debugging or advanced introspection)."
|
|
119
122
|
),
|
|
120
123
|
"result_schema": {
|
|
121
124
|
"page_id": "string",
|
|
@@ -125,6 +128,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
125
128
|
"url": "string",
|
|
126
129
|
"body_storage": "string (when --format storage, the default)",
|
|
127
130
|
"body_markdown": "string (when --format markdown)",
|
|
131
|
+
"labels": "list of {name, id, prefix} objects",
|
|
128
132
|
},
|
|
129
133
|
"examples": [
|
|
130
134
|
'confpub page inspect --space DEV --title "My Page"',
|
|
@@ -333,7 +337,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
333
337
|
"description": "Flags that can be placed at the top level or between group name and subcommand.",
|
|
334
338
|
"flags": {
|
|
335
339
|
"--quiet": "Suppress progress output on stderr",
|
|
336
|
-
"--verbose": "Include diagnostics in result",
|
|
340
|
+
"--verbose": "Include diagnostics in result (adds metrics.diagnostics with api_call_count, python_version, confpub_version, config_source, confluence_url, is_cloud; on error includes traceback)",
|
|
337
341
|
"--compact": "Output single-line JSON (no indentation)",
|
|
338
342
|
"--version": "Show version and exit (top-level only)",
|
|
339
343
|
},
|
|
@@ -9,9 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
import yaml
|
|
12
|
+
from typing import Any, Literal
|
|
15
13
|
|
|
16
14
|
from confpub.confluence import ConfluenceClient, build_client
|
|
17
15
|
from confpub.output import emit_progress
|
|
@@ -21,10 +19,13 @@ from confpub.errors import (
|
|
|
21
19
|
ERR_VALIDATION_REQUIRED,
|
|
22
20
|
ConfpubError,
|
|
23
21
|
)
|
|
22
|
+
from confpub.front_matter import FrontMatterData
|
|
24
23
|
from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
|
|
25
24
|
from confpub.manifest import generate_manifest_yaml
|
|
26
25
|
from confpub.reverse_converter import convert_storage_to_markdown
|
|
27
26
|
|
|
27
|
+
Layout = Literal["flat", "nested"]
|
|
28
|
+
|
|
28
29
|
|
|
29
30
|
def _slugify(title: str) -> str:
|
|
30
31
|
"""Convert a page title to a filename-safe slug.
|
|
@@ -77,15 +78,22 @@ def _collect_tree(
|
|
|
77
78
|
def _compute_file_paths(
|
|
78
79
|
pages: list[dict[str, Any]],
|
|
79
80
|
output_dir: str,
|
|
80
|
-
layout:
|
|
81
|
+
layout: Layout,
|
|
81
82
|
root_page_id: str,
|
|
82
|
-
) -> dict[str, str]:
|
|
83
|
-
"""Compute output file paths for each page.
|
|
83
|
+
) -> tuple[dict[str, str], dict[str, str], dict[str, str | None]]:
|
|
84
|
+
"""Compute output file paths and lookup maps for each page.
|
|
85
|
+
|
|
86
|
+
Returns (file_paths, id_to_title, id_to_parent) where:
|
|
87
|
+
- file_paths: {page_id: file_path}
|
|
88
|
+
- id_to_title: {page_id: title}
|
|
89
|
+
- id_to_parent: {page_id: parent_page_id or None}
|
|
90
|
+
"""
|
|
84
91
|
paths: dict[str, str] = {}
|
|
85
92
|
root_slug: str | None = None
|
|
86
93
|
|
|
87
|
-
# Build
|
|
94
|
+
# Build lookup maps
|
|
88
95
|
id_to_slug: dict[str, str] = {}
|
|
96
|
+
id_to_title: dict[str, str] = {}
|
|
89
97
|
id_to_parent: dict[str, str | None] = {}
|
|
90
98
|
|
|
91
99
|
for entry in pages:
|
|
@@ -93,6 +101,7 @@ def _compute_file_paths(
|
|
|
93
101
|
pid = str(page["id"])
|
|
94
102
|
slug = _slugify(page.get("title", pid))
|
|
95
103
|
id_to_slug[pid] = slug
|
|
104
|
+
id_to_title[pid] = page.get("title", "")
|
|
96
105
|
id_to_parent[pid] = str(entry["parent_id"]) if entry["parent_id"] else None
|
|
97
106
|
if pid == root_page_id:
|
|
98
107
|
root_slug = slug
|
|
@@ -119,7 +128,7 @@ def _compute_file_paths(
|
|
|
119
128
|
# Flat layout
|
|
120
129
|
paths[pid] = os.path.join(output_dir, f"{slug}.md")
|
|
121
130
|
|
|
122
|
-
return paths
|
|
131
|
+
return paths, id_to_title, id_to_parent
|
|
123
132
|
|
|
124
133
|
|
|
125
134
|
def _check_conflicts(file_paths: dict[str, str], force: bool) -> None:
|
|
@@ -147,7 +156,7 @@ def _download_page_attachments(
|
|
|
147
156
|
page_id: str,
|
|
148
157
|
slug: str,
|
|
149
158
|
output_dir: str,
|
|
150
|
-
layout:
|
|
159
|
+
layout: Layout,
|
|
151
160
|
warnings: list[str],
|
|
152
161
|
file_path: str | None = None,
|
|
153
162
|
) -> dict[str, str]:
|
|
@@ -164,8 +173,6 @@ def _download_page_attachments(
|
|
|
164
173
|
if layout == "nested" and file_path:
|
|
165
174
|
# Place assets next to the markdown file (e.g. .../page-slug/assets/)
|
|
166
175
|
assets_dir = os.path.join(os.path.dirname(file_path), "assets")
|
|
167
|
-
elif layout == "nested":
|
|
168
|
-
assets_dir = os.path.join(output_dir, slug, "assets")
|
|
169
176
|
else:
|
|
170
177
|
assets_dir = os.path.join(output_dir, "assets", slug)
|
|
171
178
|
|
|
@@ -234,27 +241,6 @@ def _build_page_tree(
|
|
|
234
241
|
return [root_entry]
|
|
235
242
|
|
|
236
243
|
|
|
237
|
-
def _build_front_matter(
|
|
238
|
-
title: str,
|
|
239
|
-
page_id: str,
|
|
240
|
-
space: str,
|
|
241
|
-
parent: str | None = None,
|
|
242
|
-
labels: list[str] | None = None,
|
|
243
|
-
) -> str:
|
|
244
|
-
"""Build a YAML front matter block for a pulled markdown file."""
|
|
245
|
-
data: dict[str, Any] = {
|
|
246
|
-
"title": title,
|
|
247
|
-
"page_id": page_id,
|
|
248
|
-
"space": space,
|
|
249
|
-
}
|
|
250
|
-
if parent:
|
|
251
|
-
data["parent"] = parent
|
|
252
|
-
if labels:
|
|
253
|
-
data["labels"] = labels
|
|
254
|
-
yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
255
|
-
return f"---\n{yaml_str}---\n\n"
|
|
256
|
-
|
|
257
|
-
|
|
258
244
|
def pull_pages(
|
|
259
245
|
*,
|
|
260
246
|
space: str | None = None,
|
|
@@ -263,7 +249,7 @@ def pull_pages(
|
|
|
263
249
|
output_dir: str = ".",
|
|
264
250
|
recursive: bool = False,
|
|
265
251
|
force: bool = False,
|
|
266
|
-
layout:
|
|
252
|
+
layout: Layout = "flat",
|
|
267
253
|
include_attachments: bool = True,
|
|
268
254
|
) -> dict[str, Any]:
|
|
269
255
|
"""Pull pages from Confluence to local Markdown files.
|
|
@@ -296,23 +282,14 @@ def pull_pages(
|
|
|
296
282
|
# Collect all pages to pull
|
|
297
283
|
all_pages = _collect_tree(client, root_id, recursive)
|
|
298
284
|
|
|
299
|
-
# Compute output file paths
|
|
300
|
-
file_paths = _compute_file_paths(all_pages, output_dir, layout, root_id)
|
|
285
|
+
# Compute output file paths and lookup maps
|
|
286
|
+
file_paths, id_to_title, id_to_parent = _compute_file_paths(all_pages, output_dir, layout, root_id)
|
|
301
287
|
|
|
302
288
|
# Check for conflicts
|
|
303
289
|
_check_conflicts(file_paths, force)
|
|
304
290
|
|
|
305
|
-
#
|
|
306
|
-
|
|
307
|
-
id_to_parent: dict[str, str | None] = {}
|
|
308
|
-
for entry in all_pages:
|
|
309
|
-
page = entry["page"]
|
|
310
|
-
pid = str(page["id"])
|
|
311
|
-
id_to_title[pid] = page.get("title", "")
|
|
312
|
-
id_to_parent[pid] = str(entry["parent_id"]) if entry["parent_id"] else None
|
|
313
|
-
|
|
314
|
-
# Get the root page's parent in Confluence (for front matter + manifest)
|
|
315
|
-
ancestors = client.get_page_ancestors(root_id)
|
|
291
|
+
# Get the root page's parent from already-fetched ancestors (no extra API call)
|
|
292
|
+
ancestors = root_page.get("ancestors", [])
|
|
316
293
|
root_parent_title = ancestors[-1].get("title", "") if ancestors else None
|
|
317
294
|
|
|
318
295
|
# Process each page
|
|
@@ -360,14 +337,14 @@ def pull_pages(
|
|
|
360
337
|
parent_title = id_to_title.get(par_id) if par_id else None
|
|
361
338
|
|
|
362
339
|
# Build and prepend front matter
|
|
363
|
-
|
|
340
|
+
fm = FrontMatterData(
|
|
364
341
|
title=page_title,
|
|
365
342
|
page_id=pid,
|
|
366
343
|
space=root_space,
|
|
367
344
|
parent=parent_title,
|
|
368
|
-
labels=page_labels,
|
|
345
|
+
labels=page_labels or [],
|
|
369
346
|
)
|
|
370
|
-
markdown_content =
|
|
347
|
+
markdown_content = fm.to_yaml_block() + conv_result.markdown
|
|
371
348
|
|
|
372
349
|
# Write markdown file
|
|
373
350
|
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
|
@@ -384,7 +361,7 @@ def pull_pages(
|
|
|
384
361
|
|
|
385
362
|
# Always generate manifest
|
|
386
363
|
root_title = root_page.get("title", "")
|
|
387
|
-
manifest_parent =
|
|
364
|
+
manifest_parent = root_parent_title or root_title
|
|
388
365
|
pulled_labels: dict[str, list[str]] = {
|
|
389
366
|
f["page_id"]: f.get("labels", []) for f in files_result
|
|
390
367
|
}
|
|
@@ -10,7 +10,8 @@ import pytest
|
|
|
10
10
|
import yaml
|
|
11
11
|
|
|
12
12
|
from confpub.errors import ERR_CONFLICT_FILE_EXISTS, ERR_VALIDATION_REQUIRED, ConfpubError
|
|
13
|
-
from confpub.
|
|
13
|
+
from confpub.front_matter import FrontMatterData
|
|
14
|
+
from confpub.puller import _slugify, pull_pages
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
# ---------------------------------------------------------------------------
|
|
@@ -594,10 +595,10 @@ class TestManifestFlag:
|
|
|
594
595
|
|
|
595
596
|
class TestBuildFrontMatter:
|
|
596
597
|
def test_basic_fields(self):
|
|
597
|
-
|
|
598
|
-
assert
|
|
599
|
-
assert
|
|
600
|
-
parsed = yaml.safe_load(
|
|
598
|
+
block = FrontMatterData(title="My Page", page_id="123", space="SD").to_yaml_block()
|
|
599
|
+
assert block.startswith("---\n")
|
|
600
|
+
assert block.endswith("---\n\n")
|
|
601
|
+
parsed = yaml.safe_load(block.strip("- \n"))
|
|
601
602
|
assert parsed["title"] == "My Page"
|
|
602
603
|
assert parsed["page_id"] == "123"
|
|
603
604
|
assert parsed["space"] == "SD"
|
|
@@ -605,14 +606,17 @@ class TestBuildFrontMatter:
|
|
|
605
606
|
assert "labels" not in parsed
|
|
606
607
|
|
|
607
608
|
def test_with_parent_and_labels(self):
|
|
608
|
-
|
|
609
|
-
|
|
609
|
+
block = FrontMatterData(
|
|
610
|
+
title="Child", page_id="456", space="SD",
|
|
611
|
+
parent="Parent Page", labels=["a", "b"],
|
|
612
|
+
).to_yaml_block()
|
|
613
|
+
parsed = yaml.safe_load(block.strip("- \n"))
|
|
610
614
|
assert parsed["parent"] == "Parent Page"
|
|
611
615
|
assert parsed["labels"] == ["a", "b"]
|
|
612
616
|
|
|
613
617
|
def test_empty_labels_omitted(self):
|
|
614
|
-
|
|
615
|
-
parsed = yaml.safe_load(
|
|
618
|
+
block = FrontMatterData(title="Page", page_id="1", space="SD").to_yaml_block()
|
|
619
|
+
parsed = yaml.safe_load(block.strip("- \n"))
|
|
616
620
|
assert "labels" not in parsed
|
|
617
621
|
|
|
618
622
|
|
|
@@ -661,8 +665,8 @@ class TestFrontMatterInPulledFiles:
|
|
|
661
665
|
def test_root_page_has_parent_from_ancestors(self, tmp_path):
|
|
662
666
|
"""Root page gets parent from Confluence ancestors."""
|
|
663
667
|
page = _make_page("1", "Root")
|
|
668
|
+
page["ancestors"] = [{"title": "Space Home"}]
|
|
664
669
|
client = _mock_client({"1": page})
|
|
665
|
-
client.get_page_ancestors = lambda pid: [{"title": "Space Home"}]
|
|
666
670
|
|
|
667
671
|
with patch("confpub.puller.build_client", return_value=client):
|
|
668
672
|
pull_pages(page_id="1", output_dir=str(tmp_path))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|