mkdocs2confluence 0.6.0__tar.gz → 0.6.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.
Files changed (67) hide show
  1. {mkdocs2confluence-0.6.0/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.6.1}/PKG-INFO +2 -1
  2. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/README.md +1 -0
  3. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/pyproject.toml +9 -2
  4. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1/src/mkdocs2confluence.egg-info}/PKG-INFO +2 -1
  5. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/loader/config.py +1 -1
  6. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/loader/nav.py +1 -3
  7. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/includes.py +1 -1
  8. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/publisher/pipeline.py +185 -140
  9. mkdocs2confluence-0.6.1/tests/test_images.py +320 -0
  10. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_preprocess.py +11 -1
  11. mkdocs2confluence-0.6.1/tests/test_preview.py +326 -0
  12. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_publish_client.py +139 -0
  13. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_publish_config.py +152 -0
  14. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_publish_pipeline.py +751 -0
  15. mkdocs2confluence-0.6.0/tests/test_images.py +0 -160
  16. mkdocs2confluence-0.6.0/tests/test_preview.py +0 -133
  17. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/LICENSE +0 -0
  18. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/setup.cfg +0 -0
  19. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  20. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  21. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  22. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  23. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  24. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/__init__.py +0 -0
  25. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/cli.py +0 -0
  26. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  27. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  28. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  29. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/ir/document.py +0 -0
  30. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  31. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  32. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  33. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  34. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/loader/page.py +0 -0
  35. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  36. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  37. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  38. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  39. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  40. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  41. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  42. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  43. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  44. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/preview/render.py +0 -0
  45. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  46. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  47. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  48. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  49. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  50. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  51. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  52. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  53. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  54. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_abbrevs.py +0 -0
  55. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_editlink.py +0 -0
  56. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_emitter.py +0 -0
  57. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_extra_css.py +0 -0
  58. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_frontmatter.py +0 -0
  59. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_icons.py +0 -0
  60. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_internallinks.py +0 -0
  61. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_ir.py +0 -0
  62. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_linkdefs.py +0 -0
  63. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_loader.py +0 -0
  64. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_mermaid.py +0 -0
  65. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_page_loader.py +0 -0
  66. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_parser.py +0 -0
  67. {mkdocs2confluence-0.6.0 → mkdocs2confluence-0.6.1}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -41,6 +41,7 @@ Dynamic: license-file
41
41
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
42
42
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
43
43
  [![PyPI](https://img.shields.io/pypi/v/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
44
+ [![Downloads](https://img.shields.io/pypi/dm/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
44
45
  [![Latest Release](https://img.shields.io/github/v/release/jeckyl2010/mkdocs2confluence)](https://github.com/jeckyl2010/mkdocs2confluence/releases/latest)
45
46
  [![CI](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml)
46
47
  [![Release](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml)
@@ -3,6 +3,7 @@
3
3
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
4
4
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
5
5
  [![PyPI](https://img.shields.io/pypi/v/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
6
+ [![Downloads](https://img.shields.io/pypi/dm/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
6
7
  [![Latest Release](https://img.shields.io/github/v/release/jeckyl2010/mkdocs2confluence)](https://github.com/jeckyl2010/mkdocs2confluence/releases/latest)
7
8
  [![CI](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml)
8
9
  [![Release](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  description = "Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more"
5
5
  readme = "README.md"
6
6
  license = { text = "GPL-3.0-or-later" }
@@ -88,4 +88,11 @@ exclude_lines = [
88
88
  "pragma: no cover",
89
89
  "if TYPE_CHECKING:",
90
90
  "raise NotImplementedError",
91
- ]
91
+ ]
92
+
93
+ [dependency-groups]
94
+ dev = [
95
+ "pip-audit>=2.10.0",
96
+ "radon>=6.0.1",
97
+ "vulture>=2.16",
98
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -41,6 +41,7 @@ Dynamic: license-file
41
41
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
42
42
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
43
43
  [![PyPI](https://img.shields.io/pypi/v/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
44
+ [![Downloads](https://img.shields.io/pypi/dm/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
44
45
  [![Latest Release](https://img.shields.io/github/v/release/jeckyl2010/mkdocs2confluence)](https://github.com/jeckyl2010/mkdocs2confluence/releases/latest)
45
46
  [![CI](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml)
46
47
  [![Release](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml)
@@ -107,7 +107,7 @@ def _make_env_loader() -> type[yaml.SafeLoader]:
107
107
 
108
108
  # Catch-all: any other unknown tag (e.g. !!python/name:... used by
109
109
  # MkDocs Material) is silently ignored — we only care about nav/site_name.
110
- def _ignore(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node) -> None:
110
+ def _ignore(loader: yaml.SafeLoader, _tag_suffix: str, node: yaml.Node) -> None:
111
111
  return None
112
112
 
113
113
  _Loader.add_multi_constructor("", _ignore) # type: ignore[no-untyped-call]
@@ -39,7 +39,7 @@ class NavNode:
39
39
  return self.docs_path is not None
40
40
 
41
41
 
42
- def resolve_nav(config: MkDocsConfig, mkdocs_root: Path | None = None) -> list[NavNode]:
42
+ def resolve_nav(config: MkDocsConfig) -> list[NavNode]:
43
43
  """Resolve *config.nav* into a list of top-level :class:`NavNode` objects.
44
44
 
45
45
  When ``config.nav`` is ``None`` (e.g. projects using ``awesome-pages`` or
@@ -48,8 +48,6 @@ def resolve_nav(config: MkDocsConfig, mkdocs_root: Path | None = None) -> list[N
48
48
 
49
49
  Args:
50
50
  config: Parsed :class:`~mkdocs_to_confluence.loader.config.MkDocsConfig`.
51
- mkdocs_root: Directory containing ``mkdocs.yml``. Used only to compute
52
- ``docs_dir`` when it is not already absolute; defaults to CWD.
53
51
 
54
52
  Returns:
55
53
  List of top-level :class:`NavNode` instances.
@@ -290,7 +290,7 @@ def _resolve_include_path(rel_path: str, source_path: Path, docs_dir: Path) -> P
290
290
  """
291
291
  for base in (docs_dir, source_path.parent):
292
292
  candidate = (base / rel_path).resolve()
293
- if candidate.is_file():
293
+ if candidate.is_file() and candidate.is_relative_to(base.resolve()):
294
294
  return candidate
295
295
  return None
296
296
 
@@ -14,6 +14,7 @@ The pipeline has two phases:
14
14
  from __future__ import annotations
15
15
 
16
16
  import hashlib
17
+ import re
17
18
  from dataclasses import dataclass, field
18
19
  from datetime import datetime, timezone
19
20
  from pathlib import Path
@@ -57,7 +58,7 @@ if TYPE_CHECKING:
57
58
 
58
59
  _Action = Literal["create", "update", "skip", "section"]
59
60
 
60
- _FRONT_MATTER_RE = __import__("re").compile(r"\A---\s*\n(.*?\n?)---\s*\n?", __import__("re").DOTALL)
61
+ _FRONT_MATTER_RE = re.compile(r"\A---\s*\n(.*?\n?)---\s*\n?", re.DOTALL)
61
62
 
62
63
 
63
64
  @dataclass
@@ -457,6 +458,184 @@ def _upload_assets(
457
458
  return uploaded, skipped, errors
458
459
 
459
460
 
461
+ def _execute_folder_action(
462
+ action: PageAction,
463
+ client: ConfluenceClient,
464
+ space_id: str,
465
+ root_page_id: str | None,
466
+ report: PublishReport,
467
+ ) -> None:
468
+ """Handle folder create/find for a single folder action."""
469
+ if action.page_id is not None:
470
+ # Already found at plan time — reuse.
471
+ report.updated += 1
472
+ elif action.parent_is_folder or action.parent_id == root_page_id:
473
+ # Parent is a Confluence folder, or this is a top-level section
474
+ # directly under the configured root page — use native folder API.
475
+ existing_folder = None
476
+ if action.parent_id is not None:
477
+ try:
478
+ existing_folder = client.find_folder_under(
479
+ action.parent_id,
480
+ action.title,
481
+ parent_is_folder=action.parent_is_folder,
482
+ )
483
+ except Exception as find_exc:
484
+ print(
485
+ f" [warn] find_folder_under failed "
486
+ f"(parent_id={action.parent_id}): {find_exc}"
487
+ )
488
+ if existing_folder is not None:
489
+ action.page_id = str(existing_folder["id"])
490
+ report.updated += 1
491
+ else:
492
+ folder_parent = (
493
+ action.parent_id if action.parent_is_folder else None
494
+ )
495
+ folder = client.create_folder(
496
+ space_id, action.title, parent_id=folder_parent
497
+ )
498
+ action.page_id = str(folder["id"])
499
+ report.created += 1
500
+ print(
501
+ f" folder id={action.page_id}"
502
+ f" parent_id={action.parent_id}"
503
+ f" parent_is_folder={action.parent_is_folder}"
504
+ )
505
+ else:
506
+ # Parent is a dynamically-created page (e.g. a section-index page).
507
+ # Confluence folders cannot be nested under pages — use a stub page.
508
+ action.is_folder = False
509
+ existing = client.find_page(space_id, action.title)
510
+ if existing is not None:
511
+ action.page_id = str(existing["id"])
512
+ report.updated += 1
513
+ else:
514
+ stub = client.create_page(
515
+ space_id, action.title, "", parent_id=action.parent_id,
516
+ )
517
+ action.page_id = str(stub["id"])
518
+ report.created += 1
519
+
520
+
521
+ def _execute_page_action(
522
+ action: PageAction,
523
+ client: ConfluenceClient,
524
+ space_id: str,
525
+ report: PublishReport,
526
+ ) -> None:
527
+ """Handle create/update (with stale fallback) for a single page action."""
528
+ if action.action == "create":
529
+ page = client.create_page(
530
+ space_id,
531
+ action.title,
532
+ action.xhtml or "",
533
+ parent_id=action.parent_id,
534
+ )
535
+ action.page_id = str(page["id"])
536
+ report.created += 1
537
+ try:
538
+ client.stamp_managed(action.page_id)
539
+ except Exception:
540
+ pass # non-fatal
541
+ elif action.action == "update":
542
+ if action.page_id is None or action.version is None:
543
+ raise RuntimeError(
544
+ f"Update action for '{action.title}' is missing page_id or version"
545
+ )
546
+ try:
547
+ client.update_page(
548
+ action.page_id,
549
+ action.title,
550
+ action.xhtml or "",
551
+ action.version + 1,
552
+ parent_id=action.parent_id,
553
+ )
554
+ report.updated += 1
555
+ except ConfluenceError as upd_exc:
556
+ err = str(upd_exc)
557
+ # 404 = page deleted; 400 "another space" = stale page_id
558
+ # from a different Confluence space. Both mean the existing
559
+ # page can't be updated — fall back to create a fresh one.
560
+ is_stale = "HTTP 404" in err or (
561
+ "HTTP 400" in err and "another space" in err.lower()
562
+ )
563
+ if not is_stale:
564
+ raise
565
+ print(
566
+ f" [warn] update failed ({err[:80].strip()}) —"
567
+ " stale page_id; falling back to create"
568
+ )
569
+ action.page_id = None
570
+ page = client.create_page(
571
+ space_id,
572
+ action.title,
573
+ action.xhtml or "",
574
+ parent_id=action.parent_id,
575
+ )
576
+ action.page_id = str(page["id"])
577
+ report.created += 1
578
+ try:
579
+ client.stamp_managed(action.page_id)
580
+ except Exception:
581
+ pass # non-fatal
582
+
583
+
584
+ def _wire_children(
585
+ action: PageAction,
586
+ action_by_node: dict[int, PageAction],
587
+ ) -> None:
588
+ """Propagate a section's resolved page_id to all its direct children."""
589
+ if action.page_id is None:
590
+ return
591
+ for child_node in action.node.children:
592
+ child_action = action_by_node.get(id(child_node))
593
+ if child_action is not None:
594
+ child_action.parent_id = action.page_id
595
+ child_action.parent_is_folder = action.is_folder
596
+
597
+
598
+ def _post_process_action(
599
+ action: PageAction,
600
+ client: ConfluenceClient,
601
+ *,
602
+ full_width: bool,
603
+ docs_dir: Path,
604
+ report: PublishReport,
605
+ ) -> None:
606
+ """Run all non-fatal post-create/update work for a single action."""
607
+ # Store content hash after create/update so the next run can skip unchanged pages.
608
+ if action.page_id and action.content_hash and action.action in ("create", "update"):
609
+ try:
610
+ client.set_content_hash(action.page_id, action.content_hash)
611
+ except Exception:
612
+ pass # non-fatal
613
+
614
+ # Set full-width layout on newly created or updated pages (not folders).
615
+ if full_width and action.page_id and not action.is_folder:
616
+ try:
617
+ client.set_page_full_width(action.page_id)
618
+ except Exception:
619
+ pass # non-fatal — page is published, layout is cosmetic
620
+
621
+ # Apply labels (tags) from front matter — non-fatal on failure.
622
+ if action.page_id and action.labels and not action.is_folder:
623
+ try:
624
+ client.set_page_labels(action.page_id, action.labels)
625
+ except Exception:
626
+ pass
627
+
628
+ # Upload assets — skip files whose mtime is not newer than Confluence.
629
+ if action.page_id and action.attachments:
630
+ uploaded, asset_skipped, asset_errors = _upload_assets(
631
+ action.page_id, action.attachments, docs_dir, client
632
+ )
633
+ report.assets_uploaded += uploaded
634
+ report.assets_skipped += asset_skipped
635
+ for name, msg in asset_errors:
636
+ report.errors.append((f"{action.title} / {name}", msg))
637
+
638
+
460
639
  def execute_publish(
461
640
  plan: list[PageAction],
462
641
  client: ConfluenceClient,
@@ -508,108 +687,9 @@ def execute_publish(
508
687
 
509
688
  try:
510
689
  if action.is_folder:
511
- if action.page_id is not None:
512
- # Already found at plan time — reuse.
513
- report.updated += 1
514
- elif action.parent_is_folder or action.parent_id == root_page_id:
515
- # Parent is a Confluence folder, or this is a top-level section
516
- # directly under the configured root page — use native folder API.
517
- existing_folder = None
518
- if action.parent_id is not None:
519
- try:
520
- existing_folder = client.find_folder_under(
521
- action.parent_id,
522
- action.title,
523
- parent_is_folder=action.parent_is_folder,
524
- )
525
- except Exception as find_exc:
526
- print(
527
- f" [warn] find_folder_under failed "
528
- f"(parent_id={action.parent_id}): {find_exc}"
529
- )
530
- if existing_folder is not None:
531
- action.page_id = str(existing_folder["id"])
532
- report.updated += 1
533
- else:
534
- folder_parent = (
535
- action.parent_id if action.parent_is_folder else None
536
- )
537
- folder = client.create_folder(
538
- space_id, action.title, parent_id=folder_parent
539
- )
540
- action.page_id = str(folder["id"])
541
- report.created += 1
542
- print(
543
- f" folder id={action.page_id}"
544
- f" parent_id={action.parent_id}"
545
- f" parent_is_folder={action.parent_is_folder}"
546
- )
547
- else:
548
- # Parent is a dynamically-created page (e.g. a section-index page).
549
- # Confluence folders cannot be nested under pages — use a stub page.
550
- action.is_folder = False
551
- existing = client.find_page(space_id, action.title)
552
- if existing is not None:
553
- action.page_id = str(existing["id"])
554
- report.updated += 1
555
- else:
556
- stub = client.create_page(
557
- space_id, action.title, "", parent_id=action.parent_id,
558
- )
559
- action.page_id = str(stub["id"])
560
- report.created += 1
561
- elif action.action == "create":
562
- page = client.create_page(
563
- space_id,
564
- action.title,
565
- action.xhtml or "",
566
- parent_id=action.parent_id,
567
- )
568
- action.page_id = str(page["id"])
569
- report.created += 1
570
- try:
571
- client.stamp_managed(action.page_id)
572
- except Exception:
573
- pass # non-fatal
574
- elif action.action == "update":
575
- assert action.page_id is not None
576
- assert action.version is not None
577
- try:
578
- client.update_page(
579
- action.page_id,
580
- action.title,
581
- action.xhtml or "",
582
- action.version + 1,
583
- parent_id=action.parent_id,
584
- )
585
- report.updated += 1
586
- except ConfluenceError as upd_exc:
587
- err = str(upd_exc)
588
- # 404 = page deleted; 400 "another space" = stale page_id
589
- # from a different Confluence space. Both mean the existing
590
- # page can't be updated — fall back to create a fresh one.
591
- is_stale = "HTTP 404" in err or (
592
- "HTTP 400" in err and "another space" in err.lower()
593
- )
594
- if not is_stale:
595
- raise
596
- print(
597
- f" [warn] update failed ({err[:80].strip()}) —"
598
- " stale page_id; falling back to create"
599
- )
600
- action.page_id = None
601
- page = client.create_page(
602
- space_id,
603
- action.title,
604
- action.xhtml or "",
605
- parent_id=action.parent_id,
606
- )
607
- action.page_id = str(page["id"])
608
- report.created += 1
609
- try:
610
- client.stamp_managed(action.page_id)
611
- except Exception:
612
- pass # non-fatal
690
+ _execute_folder_action(action, client, space_id, root_page_id, report)
691
+ else:
692
+ _execute_page_action(action, client, space_id, report)
613
693
  except Exception as exc:
614
694
  report.errors.append((action.title, str(exc)))
615
695
  # Do NOT `continue` — all post-execute blocks below are guarded by
@@ -617,45 +697,10 @@ def execute_publish(
617
697
  # Critically, child parent_id wiring must still run for section
618
698
  # pages whose children were planned with parent_id=None.
619
699
 
620
- # Once a folder/section's page_id is known, wire it into all direct
621
- # children so that children created later use the correct parent_id.
622
700
  if action.node.is_section and action.page_id:
623
- for child_node in action.node.children:
624
- child_action = action_by_node.get(id(child_node))
625
- if child_action is not None:
626
- child_action.parent_id = action.page_id
627
- child_action.parent_is_folder = action.is_folder
628
-
629
- # Store content hash after create/update so the next run can skip unchanged pages.
630
- if action.page_id and action.content_hash and action.action in ("create", "update"):
631
- try:
632
- client.set_content_hash(action.page_id, action.content_hash)
633
- except Exception:
634
- pass # non-fatal
635
-
636
- # Set full-width layout on newly created or updated pages (not folders).
637
- if full_width and action.page_id and not action.is_folder:
638
- try:
639
- client.set_page_full_width(action.page_id)
640
- except Exception:
641
- pass # non-fatal — page is published, layout is cosmetic
701
+ _wire_children(action, action_by_node)
642
702
 
643
- # Apply labels (tags) from front matter — non-fatal on failure.
644
- if action.page_id and action.labels and not action.is_folder:
645
- try:
646
- client.set_page_labels(action.page_id, action.labels)
647
- except Exception:
648
- pass
649
-
650
- # Upload assets — skip files whose mtime is not newer than Confluence.
651
- if action.page_id and action.attachments:
652
- uploaded, asset_skipped, asset_errors = _upload_assets(
653
- action.page_id, action.attachments, docs_dir, client
654
- )
655
- report.assets_uploaded += uploaded
656
- report.assets_skipped += asset_skipped
657
- for name, msg in asset_errors:
658
- report.errors.append((f"{action.title} / {name}", msg))
703
+ _post_process_action(action, client, full_width=full_width, docs_dir=docs_dir, report=report)
659
704
 
660
705
  if prune and root_page_id:
661
706
  published_ids = {a.page_id for a in plan if a.page_id}