mkdocs2confluence 0.5.27__tar.gz → 0.6.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.
Files changed (65) hide show
  1. {mkdocs2confluence-0.5.27/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.6.0}/PKG-INFO +19 -3
  2. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/README.md +18 -2
  3. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0/src/mkdocs2confluence.egg-info}/PKG-INFO +19 -3
  5. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/__init__.py +1 -1
  6. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/cli.py +13 -0
  7. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/publisher/client.py +64 -0
  8. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/publisher/pipeline.py +49 -0
  9. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_publish_client.py +98 -0
  10. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_publish_pipeline.py +136 -0
  11. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/LICENSE +0 -0
  12. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/setup.cfg +0 -0
  13. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  14. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  15. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  16. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  17. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  18. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  19. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  20. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  21. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/ir/document.py +0 -0
  22. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  23. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  24. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  25. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/loader/config.py +0 -0
  26. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  27. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  28. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/loader/page.py +0 -0
  29. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  30. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  31. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  32. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  33. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  34. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  35. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  36. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  37. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  38. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  39. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/preview/render.py +0 -0
  40. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  41. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  42. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  43. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  44. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  45. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  46. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  47. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  48. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_abbrevs.py +0 -0
  49. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_editlink.py +0 -0
  50. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_emitter.py +0 -0
  51. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_extra_css.py +0 -0
  52. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_frontmatter.py +0 -0
  53. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_icons.py +0 -0
  54. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_images.py +0 -0
  55. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_internallinks.py +0 -0
  56. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_ir.py +0 -0
  57. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_linkdefs.py +0 -0
  58. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_loader.py +0 -0
  59. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_mermaid.py +0 -0
  60. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_page_loader.py +0 -0
  61. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_parser.py +0 -0
  62. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_preprocess.py +0 -0
  63. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_preview.py +0 -0
  64. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_publish_config.py +0 -0
  65. {mkdocs2confluence-0.5.27 → mkdocs2confluence-0.6.0}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.5.27
3
+ Version: 0.6.0
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
@@ -40,6 +40,7 @@ Dynamic: license-file
40
40
 
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
+ [![PyPI](https://img.shields.io/pypi/v/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
43
44
  [![Latest Release](https://img.shields.io/github/v/release/jeckyl2010/mkdocs2confluence)](https://github.com/jeckyl2010/mkdocs2confluence/releases/latest)
44
45
  [![CI](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml)
45
46
  [![Release](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml)
@@ -132,7 +133,7 @@ The `--html` flag renders Confluence macros as visual HTML panels so you can rev
132
133
  Compile all pages listed in `nav:` and publish them to Confluence Cloud.
133
134
 
134
135
  ```
135
- mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--report FILE]
136
+ mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--report FILE] [--prune]
136
137
  ```
137
138
 
138
139
  | Flag | Default | Description |
@@ -142,6 +143,7 @@ mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--re
142
143
  | `--section PATH` | *(whole nav)* | Publish only a nav subtree (e.g. `Guide` or `Guide/Setup`) |
143
144
  | `--dry-run` | off | Print the publish plan without making any API calls |
144
145
  | `--report FILE` | *(none)* | Write a JSON publish report to `FILE` |
146
+ | `--prune` | off | Delete managed Confluence pages no longer in `nav:` (see [Orphan pruning](#orphan-pruning)) |
145
147
 
146
148
  #### Configuration
147
149
 
@@ -169,6 +171,20 @@ The API token is read from (in priority order):
169
171
  - Pages with `ready: false` in their YAML front matter are **skipped**, even if listed in the nav.
170
172
  - Section nodes (nav groups without a page) become empty parent pages in Confluence, mirroring the nav hierarchy.
171
173
  - All locally linked assets are uploaded as Confluence page attachments automatically.
174
+ - **Smart update detection** — before calling the Confluence update API, mk2conf compares a `sha256` hash of the compiled output against the hash stored from the previous run (kept as a hidden Confluence page property `mk2conf-content-hash`). Pages whose content has not changed are skipped entirely — no version bump, no watcher notification.
175
+ - **Orphan pruning** — every page created by mk2conf is stamped with a hidden `mk2conf-managed` property. Pass `--prune` to automatically delete managed pages that have been removed from `nav:`. Manually-created Confluence pages are never deleted.
176
+
177
+ #### Orphan pruning
178
+
179
+ ```bash
180
+ mk2conf publish --prune
181
+ ```
182
+
183
+ After each publish run, `--prune` fetches all descendants of the configured `parent_page_id`, diffs them against the pages just published, and deletes any managed orphans — i.e. pages that carry the `mk2conf-managed` stamp but are no longer in the MkDocs nav.
184
+
185
+ > **Safety:** Only pages stamped by mk2conf are eligible for deletion. Any page created directly in Confluence will never be touched, even if it sits inside the managed hierarchy.
186
+ >
187
+ > **Partial runs:** `--prune` is silently ignored when `--page` or `--section` is used, because a partial publish would only cover a subset of the nav and would incorrectly identify out-of-scope pages as orphans.
172
188
 
173
189
  #### Mermaid rendering
174
190
 
@@ -319,7 +335,7 @@ Any unrecognised block is preserved as a visible `warning` macro — no content
319
335
 
320
336
  ## Roadmap
321
337
 
322
- - [ ] **Delete orphaned pages** — detect and remove Confluence pages that were previously published but have since been removed from `nav:`.
338
+ - [x] **Delete orphaned pages** — `--prune` detects and removes managed Confluence pages that have been removed from `nav:`. Manually-created pages are never deleted.
323
339
 
324
340
  ---
325
341
 
@@ -2,6 +2,7 @@
2
2
 
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
+ [![PyPI](https://img.shields.io/pypi/v/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
5
6
  [![Latest Release](https://img.shields.io/github/v/release/jeckyl2010/mkdocs2confluence)](https://github.com/jeckyl2010/mkdocs2confluence/releases/latest)
6
7
  [![CI](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml)
7
8
  [![Release](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml)
@@ -94,7 +95,7 @@ The `--html` flag renders Confluence macros as visual HTML panels so you can rev
94
95
  Compile all pages listed in `nav:` and publish them to Confluence Cloud.
95
96
 
96
97
  ```
97
- mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--report FILE]
98
+ mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--report FILE] [--prune]
98
99
  ```
99
100
 
100
101
  | Flag | Default | Description |
@@ -104,6 +105,7 @@ mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--re
104
105
  | `--section PATH` | *(whole nav)* | Publish only a nav subtree (e.g. `Guide` or `Guide/Setup`) |
105
106
  | `--dry-run` | off | Print the publish plan without making any API calls |
106
107
  | `--report FILE` | *(none)* | Write a JSON publish report to `FILE` |
108
+ | `--prune` | off | Delete managed Confluence pages no longer in `nav:` (see [Orphan pruning](#orphan-pruning)) |
107
109
 
108
110
  #### Configuration
109
111
 
@@ -131,6 +133,20 @@ The API token is read from (in priority order):
131
133
  - Pages with `ready: false` in their YAML front matter are **skipped**, even if listed in the nav.
132
134
  - Section nodes (nav groups without a page) become empty parent pages in Confluence, mirroring the nav hierarchy.
133
135
  - All locally linked assets are uploaded as Confluence page attachments automatically.
136
+ - **Smart update detection** — before calling the Confluence update API, mk2conf compares a `sha256` hash of the compiled output against the hash stored from the previous run (kept as a hidden Confluence page property `mk2conf-content-hash`). Pages whose content has not changed are skipped entirely — no version bump, no watcher notification.
137
+ - **Orphan pruning** — every page created by mk2conf is stamped with a hidden `mk2conf-managed` property. Pass `--prune` to automatically delete managed pages that have been removed from `nav:`. Manually-created Confluence pages are never deleted.
138
+
139
+ #### Orphan pruning
140
+
141
+ ```bash
142
+ mk2conf publish --prune
143
+ ```
144
+
145
+ After each publish run, `--prune` fetches all descendants of the configured `parent_page_id`, diffs them against the pages just published, and deletes any managed orphans — i.e. pages that carry the `mk2conf-managed` stamp but are no longer in the MkDocs nav.
146
+
147
+ > **Safety:** Only pages stamped by mk2conf are eligible for deletion. Any page created directly in Confluence will never be touched, even if it sits inside the managed hierarchy.
148
+ >
149
+ > **Partial runs:** `--prune` is silently ignored when `--page` or `--section` is used, because a partial publish would only cover a subset of the nav and would incorrectly identify out-of-scope pages as orphans.
134
150
 
135
151
  #### Mermaid rendering
136
152
 
@@ -281,7 +297,7 @@ Any unrecognised block is preserved as a visible `warning` macro — no content
281
297
 
282
298
  ## Roadmap
283
299
 
284
- - [ ] **Delete orphaned pages** — detect and remove Confluence pages that were previously published but have since been removed from `nav:`.
300
+ - [x] **Delete orphaned pages** — `--prune` detects and removes managed Confluence pages that have been removed from `nav:`. Manually-created pages are never deleted.
285
301
 
286
302
  ---
287
303
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.5.27"
3
+ version = "0.6.0"
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" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.5.27
3
+ Version: 0.6.0
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
@@ -40,6 +40,7 @@ Dynamic: license-file
40
40
 
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
+ [![PyPI](https://img.shields.io/pypi/v/mkdocs2confluence)](https://pypi.org/project/mkdocs2confluence/)
43
44
  [![Latest Release](https://img.shields.io/github/v/release/jeckyl2010/mkdocs2confluence)](https://github.com/jeckyl2010/mkdocs2confluence/releases/latest)
44
45
  [![CI](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/ci.yml)
45
46
  [![Release](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml/badge.svg)](https://github.com/jeckyl2010/mkdocs2confluence/actions/workflows/release.yml)
@@ -132,7 +133,7 @@ The `--html` flag renders Confluence macros as visual HTML panels so you can rev
132
133
  Compile all pages listed in `nav:` and publish them to Confluence Cloud.
133
134
 
134
135
  ```
135
- mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--report FILE]
136
+ mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--report FILE] [--prune]
136
137
  ```
137
138
 
138
139
  | Flag | Default | Description |
@@ -142,6 +143,7 @@ mk2conf publish [--config PATH] [--page PATH] [--section PATH] [--dry-run] [--re
142
143
  | `--section PATH` | *(whole nav)* | Publish only a nav subtree (e.g. `Guide` or `Guide/Setup`) |
143
144
  | `--dry-run` | off | Print the publish plan without making any API calls |
144
145
  | `--report FILE` | *(none)* | Write a JSON publish report to `FILE` |
146
+ | `--prune` | off | Delete managed Confluence pages no longer in `nav:` (see [Orphan pruning](#orphan-pruning)) |
145
147
 
146
148
  #### Configuration
147
149
 
@@ -169,6 +171,20 @@ The API token is read from (in priority order):
169
171
  - Pages with `ready: false` in their YAML front matter are **skipped**, even if listed in the nav.
170
172
  - Section nodes (nav groups without a page) become empty parent pages in Confluence, mirroring the nav hierarchy.
171
173
  - All locally linked assets are uploaded as Confluence page attachments automatically.
174
+ - **Smart update detection** — before calling the Confluence update API, mk2conf compares a `sha256` hash of the compiled output against the hash stored from the previous run (kept as a hidden Confluence page property `mk2conf-content-hash`). Pages whose content has not changed are skipped entirely — no version bump, no watcher notification.
175
+ - **Orphan pruning** — every page created by mk2conf is stamped with a hidden `mk2conf-managed` property. Pass `--prune` to automatically delete managed pages that have been removed from `nav:`. Manually-created Confluence pages are never deleted.
176
+
177
+ #### Orphan pruning
178
+
179
+ ```bash
180
+ mk2conf publish --prune
181
+ ```
182
+
183
+ After each publish run, `--prune` fetches all descendants of the configured `parent_page_id`, diffs them against the pages just published, and deletes any managed orphans — i.e. pages that carry the `mk2conf-managed` stamp but are no longer in the MkDocs nav.
184
+
185
+ > **Safety:** Only pages stamped by mk2conf are eligible for deletion. Any page created directly in Confluence will never be touched, even if it sits inside the managed hierarchy.
186
+ >
187
+ > **Partial runs:** `--prune` is silently ignored when `--page` or `--section` is used, because a partial publish would only cover a subset of the nav and would incorrectly identify out-of-scope pages as orphans.
172
188
 
173
189
  #### Mermaid rendering
174
190
 
@@ -319,7 +335,7 @@ Any unrecognised block is preserved as a visible `warning` macro — no content
319
335
 
320
336
  ## Roadmap
321
337
 
322
- - [ ] **Delete orphaned pages** — detect and remove Confluence pages that were previously published but have since been removed from `nav:`.
338
+ - [x] **Delete orphaned pages** — `--prune` detects and removes managed Confluence pages that have been removed from `nav:`. Manually-created pages are never deleted.
323
339
 
324
340
  ---
325
341
 
@@ -3,6 +3,6 @@
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
5
  try:
6
- __version__ = version("mkdocs-to-confluence")
6
+ __version__ = version("mkdocs2confluence")
7
7
  except PackageNotFoundError:
8
8
  __version__ = "unknown"
@@ -115,6 +115,15 @@ def _build_parser() -> argparse.ArgumentParser:
115
115
  default=None,
116
116
  help="Write a JSON publish report to FILE after the run.",
117
117
  )
118
+ publish.add_argument(
119
+ "--prune",
120
+ action="store_true",
121
+ help=(
122
+ "Delete managed Confluence pages that are no longer in the MkDocs nav. "
123
+ "Only pages stamped by mk2conf are eligible — manually-created pages are never deleted. "
124
+ "Ignored when --page or --section is used."
125
+ ),
126
+ )
118
127
 
119
128
  return parser
120
129
 
@@ -308,10 +317,14 @@ def _cmd_publish(args: argparse.Namespace) -> None:
308
317
  )
309
318
  sys.exit(1)
310
319
  plan = plan_publish(nav_nodes, client, config, conf_config, space_id=space_id)
320
+ # --prune is silently disabled for partial publishes (--page / --section)
321
+ # because published_ids would only cover the subset, not the full nav.
322
+ partial = bool(getattr(args, "page", None) or getattr(args, "section", None))
311
323
  report = execute_publish(
312
324
  plan, client, dry_run=False, space_id=space_id,
313
325
  docs_dir=config.docs_dir, full_width=conf_config.full_width,
314
326
  root_page_id=conf_config.parent_page_id,
327
+ prune=getattr(args, "prune", False) and not partial,
315
328
  )
316
329
  except ConfluenceError as exc:
317
330
  print(f"error: {exc}", file=sys.stderr)
@@ -9,12 +9,20 @@ from __future__ import annotations
9
9
  import base64
10
10
  from pathlib import Path
11
11
  from typing import Any, cast
12
+ from urllib.parse import parse_qs, urlparse
12
13
 
13
14
  import httpx
14
15
 
15
16
  from mkdocs_to_confluence.loader.config import ConfluenceConfig
16
17
 
17
18
 
19
+ def _extract_cursor(next_url: str) -> str:
20
+ """Extract the ``cursor`` query parameter from a pagination ``next`` URL."""
21
+ qs = parse_qs(urlparse(next_url).query)
22
+ cursors = qs.get("cursor", [])
23
+ return cursors[0] if cursors else ""
24
+
25
+
18
26
  class ConfluenceError(RuntimeError):
19
27
  """Raised when the Confluence API returns an unexpected response."""
20
28
 
@@ -409,3 +417,59 @@ class ConfluenceClient:
409
417
  headers={"X-Atlassian-Token": "no-check"},
410
418
  )
411
419
  self._raise_for_status(resp, f"upload_attachment({filename!r})")
420
+
421
+ # ── Orphan detection ───────────────────────────────────────────────────────
422
+
423
+ def stamp_managed(self, page_id: str) -> None:
424
+ """Mark *page_id* as managed by mk2conf via a v2 content property.
425
+
426
+ The property ``mk2conf-managed`` is set to ``true`` on first publish and
427
+ never updated. It is used by :meth:`is_managed` to distinguish pages
428
+ created by mk2conf from manually-created Confluence pages.
429
+
430
+ Errors are swallowed — this is a best-effort stamp and must never block
431
+ a publish.
432
+ """
433
+ url = self._v2(f"/pages/{page_id}/properties/mk2conf-managed")
434
+ get_resp = self._http.get(url)
435
+ if get_resp.status_code == 200:
436
+ return # already stamped
437
+ self._http.post(
438
+ self._v2(f"/pages/{page_id}/properties"),
439
+ json={"key": "mk2conf-managed", "value": True},
440
+ )
441
+
442
+ def get_descendant_ids(self, page_id: str) -> list[str]:
443
+ """Return all descendant page IDs under *page_id* at all depths.
444
+
445
+ Uses ``GET /wiki/api/v2/pages/{id}/descendants?depth=all``.
446
+ Paginates automatically via cursor. Returns page IDs only — callers
447
+ that need to filter by managed status use :meth:`is_managed`.
448
+ """
449
+ ids: list[str] = []
450
+ url = self._v2(f"/pages/{page_id}/descendants")
451
+ params: dict[str, str | int] = {"depth": "all", "limit": 250}
452
+ while True:
453
+ resp = self._http.get(url, params=params)
454
+ self._raise_for_status(resp, f"get_descendant_ids({page_id!r})")
455
+ data = resp.json()
456
+ for item in data.get("results", []):
457
+ if item.get("type") == "page":
458
+ ids.append(str(item["id"]))
459
+ next_url = data.get("_links", {}).get("next")
460
+ if not next_url:
461
+ break
462
+ # next_url is an absolute path — extract cursor and re-request
463
+ url = self._v2(f"/pages/{page_id}/descendants")
464
+ params = {"depth": "all", "limit": 250, "cursor": _extract_cursor(next_url)}
465
+ return ids
466
+
467
+ def is_managed(self, page_id: str) -> bool:
468
+ """Return ``True`` if *page_id* has the ``mk2conf-managed`` property."""
469
+ resp = self._http.get(self._v2(f"/pages/{page_id}/properties/mk2conf-managed"))
470
+ return resp.status_code == 200
471
+
472
+ def delete_page(self, page_id: str) -> None:
473
+ """Permanently delete *page_id* from Confluence."""
474
+ resp = self._http.delete(self._v2(f"/pages/{page_id}"))
475
+ self._raise_for_status(resp, f"delete_page({page_id!r})")
@@ -88,6 +88,7 @@ class PublishReport:
88
88
  skipped: int = 0
89
89
  assets_uploaded: int = 0
90
90
  assets_skipped: int = 0
91
+ pruned: int = 0
91
92
  errors: list[tuple[str, str]] = field(default_factory=list)
92
93
 
93
94
  @property
@@ -99,6 +100,8 @@ class PublishReport:
99
100
  f"Published: {self.created} created, {self.updated} updated, {self.skipped} skipped",
100
101
  f"Assets: {self.assets_uploaded} uploaded, {self.assets_skipped} skipped",
101
102
  ]
103
+ if self.pruned:
104
+ lines.append(f"Pruned: {self.pruned} orphaned page(s) deleted")
102
105
  if self.errors:
103
106
  lines.append(f"Errors: {len(self.errors)}")
104
107
  for title, msg in self.errors:
@@ -463,6 +466,7 @@ def execute_publish(
463
466
  docs_dir: Path,
464
467
  full_width: bool = True,
465
468
  root_page_id: str | None = None,
469
+ prune: bool = False,
466
470
  ) -> PublishReport:
467
471
  """Execute the publish plan.
468
472
 
@@ -563,6 +567,10 @@ def execute_publish(
563
567
  )
564
568
  action.page_id = str(page["id"])
565
569
  report.created += 1
570
+ try:
571
+ client.stamp_managed(action.page_id)
572
+ except Exception:
573
+ pass # non-fatal
566
574
  elif action.action == "update":
567
575
  assert action.page_id is not None
568
576
  assert action.version is not None
@@ -598,6 +606,10 @@ def execute_publish(
598
606
  )
599
607
  action.page_id = str(page["id"])
600
608
  report.created += 1
609
+ try:
610
+ client.stamp_managed(action.page_id)
611
+ except Exception:
612
+ pass # non-fatal
601
613
  except Exception as exc:
602
614
  report.errors.append((action.title, str(exc)))
603
615
  # Do NOT `continue` — all post-execute blocks below are guarded by
@@ -645,4 +657,41 @@ def execute_publish(
645
657
  for name, msg in asset_errors:
646
658
  report.errors.append((f"{action.title} / {name}", msg))
647
659
 
660
+ if prune and root_page_id:
661
+ published_ids = {a.page_id for a in plan if a.page_id}
662
+ _prune_orphans(client, root_page_id, published_ids, report)
663
+
648
664
  return report
665
+
666
+
667
+ def _prune_orphans(
668
+ client: ConfluenceClient,
669
+ root_page_id: str,
670
+ published_ids: set[str],
671
+ report: PublishReport,
672
+ ) -> None:
673
+ """Delete managed descendant pages that are no longer in the publish plan.
674
+
675
+ Only pages that carry the ``mk2conf-managed`` property are eligible for
676
+ deletion — manually-created Confluence pages are left untouched.
677
+ """
678
+ try:
679
+ all_descendants = client.get_descendant_ids(root_page_id)
680
+ except Exception as exc:
681
+ print(f" [warn] prune: could not fetch descendants — {exc}")
682
+ return
683
+
684
+ orphan_candidates = [pid for pid in all_descendants if pid not in published_ids]
685
+ if not orphan_candidates:
686
+ return
687
+
688
+ print(f"\nPruning: checking {len(orphan_candidates)} orphan candidate(s)...")
689
+ for page_id in orphan_candidates:
690
+ try:
691
+ if not client.is_managed(page_id):
692
+ continue
693
+ client.delete_page(page_id)
694
+ report.pruned += 1
695
+ print(f" deleted orphan page {page_id}")
696
+ except Exception as exc:
697
+ print(f" [warn] prune: failed to delete page {page_id} — {exc}")
@@ -490,3 +490,101 @@ def test_upload_attachment_error_raises(tmp_path: Path) -> None:
490
490
  client._client = httpx.Client(transport=transport) # type: ignore[assignment]
491
491
  with pytest.raises(ConfluenceError, match="403"):
492
492
  client.upload_attachment("99", img, "img.png")
493
+
494
+
495
+ # ── Orphan detection ───────────────────────────────────────────────────────────
496
+
497
+
498
+ def test_stamp_managed_posts_when_absent() -> None:
499
+ transport = _MockTransport(
500
+ httpx.Response(404, text="not found"), # GET — absent
501
+ httpx.Response(200, json={}), # POST — create
502
+ )
503
+ config = _make_config()
504
+ with ConfluenceClient(config) as client:
505
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
506
+ client.stamp_managed("42")
507
+ assert transport.requests[0].method == "GET"
508
+ assert "/properties/mk2conf-managed" in str(transport.requests[0].url)
509
+ assert transport.requests[1].method == "POST"
510
+ body = json.loads(transport.requests[1].content)
511
+ assert body["key"] == "mk2conf-managed"
512
+ assert body["value"] is True
513
+
514
+
515
+ def test_stamp_managed_skips_when_already_stamped() -> None:
516
+ transport = _MockTransport(httpx.Response(200, json={"key": "mk2conf-managed", "value": True}))
517
+ config = _make_config()
518
+ with ConfluenceClient(config) as client:
519
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
520
+ client.stamp_managed("42")
521
+ assert len(transport.requests) == 1 # only the GET, no POST
522
+
523
+
524
+ def test_get_descendant_ids_returns_page_ids() -> None:
525
+ payload = {
526
+ "results": [
527
+ {"id": "10", "type": "page"},
528
+ {"id": "11", "type": "page"},
529
+ ],
530
+ "_links": {},
531
+ }
532
+ transport = _MockTransport(_json_response(payload))
533
+ config = _make_config()
534
+ with ConfluenceClient(config) as client:
535
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
536
+ ids = client.get_descendant_ids("99")
537
+ assert ids == ["10", "11"]
538
+
539
+
540
+ def test_get_descendant_ids_paginates() -> None:
541
+ page1 = {
542
+ "results": [{"id": "10", "type": "page"}],
543
+ "_links": {"next": "/wiki/api/v2/pages/99/descendants?cursor=abc&depth=all"},
544
+ }
545
+ page2 = {
546
+ "results": [{"id": "11", "type": "page"}],
547
+ "_links": {},
548
+ }
549
+ transport = _MockTransport(_json_response(page1), _json_response(page2))
550
+ config = _make_config()
551
+ with ConfluenceClient(config) as client:
552
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
553
+ ids = client.get_descendant_ids("99")
554
+ assert ids == ["10", "11"]
555
+ assert len(transport.requests) == 2
556
+
557
+
558
+ def test_is_managed_returns_true_when_present() -> None:
559
+ transport = _MockTransport(httpx.Response(200, json={"key": "mk2conf-managed", "value": True}))
560
+ config = _make_config()
561
+ with ConfluenceClient(config) as client:
562
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
563
+ assert client.is_managed("42") is True
564
+
565
+
566
+ def test_is_managed_returns_false_when_absent() -> None:
567
+ transport = _MockTransport(httpx.Response(404, text="not found"))
568
+ config = _make_config()
569
+ with ConfluenceClient(config) as client:
570
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
571
+ assert client.is_managed("42") is False
572
+
573
+
574
+ def test_delete_page_sends_delete_request() -> None:
575
+ transport = _MockTransport(httpx.Response(204, text=""))
576
+ config = _make_config()
577
+ with ConfluenceClient(config) as client:
578
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
579
+ client.delete_page("42")
580
+ assert transport.requests[0].method == "DELETE"
581
+ assert "/pages/42" in str(transport.requests[0].url)
582
+
583
+
584
+ def test_delete_page_raises_on_error() -> None:
585
+ transport = _MockTransport(httpx.Response(403, text="forbidden"))
586
+ config = _make_config()
587
+ with ConfluenceClient(config) as client:
588
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
589
+ with pytest.raises(ConfluenceError):
590
+ client.delete_page("42")
@@ -998,3 +998,139 @@ class TestExecutePublish:
998
998
  assert section_action.page_id is not None
999
999
  assert section_action.page_id != "old-space-stale-id"
1000
1000
  assert child_action.parent_id == section_action.page_id
1001
+
1002
+
1003
+ class TestPruneOrphans:
1004
+ """Tests for orphaned page detection and pruning."""
1005
+
1006
+ def test_prune_deletes_managed_orphan(self, tmp_path: Path) -> None:
1007
+ """A managed descendant not in published_ids must be deleted."""
1008
+ from mkdocs_to_confluence.publisher.pipeline import execute_publish
1009
+
1010
+ docs_dir = tmp_path / "docs"
1011
+ docs_dir.mkdir()
1012
+ page = _make_page_node("My Page", tmp_path, docs_dir)
1013
+ action = PageAction(node=page, title="My Page", action="create", parent_id="ROOT")
1014
+
1015
+ client = _make_execute_client()
1016
+ client.get_descendant_ids.return_value = ["orphan-99"]
1017
+ client.is_managed.return_value = True
1018
+
1019
+ report = execute_publish(
1020
+ [action], client, space_id="~42", docs_dir=docs_dir,
1021
+ root_page_id="ROOT", prune=True,
1022
+ )
1023
+
1024
+ client.delete_page.assert_called_once_with("orphan-99")
1025
+ assert report.pruned == 1
1026
+
1027
+ def test_prune_skips_unmanaged_orphan(self, tmp_path: Path) -> None:
1028
+ """A manually-created page (no mk2conf-managed stamp) must not be deleted."""
1029
+ from mkdocs_to_confluence.publisher.pipeline import execute_publish
1030
+
1031
+ docs_dir = tmp_path / "docs"
1032
+ docs_dir.mkdir()
1033
+ page = _make_page_node("My Page", tmp_path, docs_dir)
1034
+ action = PageAction(node=page, title="My Page", action="create", parent_id="ROOT")
1035
+
1036
+ client = _make_execute_client()
1037
+ client.get_descendant_ids.return_value = ["manual-page-55"]
1038
+ client.is_managed.return_value = False
1039
+
1040
+ report = execute_publish(
1041
+ [action], client, space_id="~42", docs_dir=docs_dir,
1042
+ root_page_id="ROOT", prune=True,
1043
+ )
1044
+
1045
+ client.delete_page.assert_not_called()
1046
+ assert report.pruned == 0
1047
+
1048
+ def test_prune_skips_published_pages(self, tmp_path: Path) -> None:
1049
+ """Published pages that are descendants must not be deleted."""
1050
+ from mkdocs_to_confluence.publisher.pipeline import execute_publish
1051
+
1052
+ docs_dir = tmp_path / "docs"
1053
+ docs_dir.mkdir()
1054
+ page = _make_page_node("My Page", tmp_path, docs_dir)
1055
+ action = PageAction(node=page, title="My Page", action="create", parent_id="ROOT")
1056
+
1057
+ client = _make_execute_client()
1058
+ # The newly created page has an id set by create_page side_effect (101)
1059
+ # We simulate get_descendant_ids returning that same id
1060
+ client.get_descendant_ids.return_value = ["101"]
1061
+
1062
+ report = execute_publish(
1063
+ [action], client, space_id="~42", docs_dir=docs_dir,
1064
+ root_page_id="ROOT", prune=True,
1065
+ )
1066
+
1067
+ client.is_managed.assert_not_called()
1068
+ client.delete_page.assert_not_called()
1069
+ assert report.pruned == 0
1070
+
1071
+ def test_prune_disabled_by_default(self, tmp_path: Path) -> None:
1072
+ """Without prune=True, descendant IDs are never fetched."""
1073
+ from mkdocs_to_confluence.publisher.pipeline import execute_publish
1074
+
1075
+ docs_dir = tmp_path / "docs"
1076
+ docs_dir.mkdir()
1077
+ page = _make_page_node("My Page", tmp_path, docs_dir)
1078
+ action = PageAction(node=page, title="My Page", action="create", parent_id="ROOT")
1079
+
1080
+ client = _make_execute_client()
1081
+
1082
+ execute_publish(
1083
+ [action], client, space_id="~42", docs_dir=docs_dir,
1084
+ root_page_id="ROOT",
1085
+ )
1086
+
1087
+ client.get_descendant_ids.assert_not_called()
1088
+
1089
+ def test_prune_disabled_when_no_root_page_id(self, tmp_path: Path) -> None:
1090
+ """prune=True without root_page_id does nothing."""
1091
+ from mkdocs_to_confluence.publisher.pipeline import execute_publish
1092
+
1093
+ docs_dir = tmp_path / "docs"
1094
+ docs_dir.mkdir()
1095
+ page = _make_page_node("My Page", tmp_path, docs_dir)
1096
+ action = PageAction(node=page, title="My Page", action="create", parent_id=None)
1097
+
1098
+ client = _make_execute_client()
1099
+
1100
+ execute_publish(
1101
+ [action], client, space_id="~42", docs_dir=docs_dir,
1102
+ prune=True, # no root_page_id
1103
+ )
1104
+
1105
+ client.get_descendant_ids.assert_not_called()
1106
+
1107
+ def test_stamp_managed_called_on_create(self, tmp_path: Path) -> None:
1108
+ """stamp_managed must be called for each newly created page."""
1109
+ from mkdocs_to_confluence.publisher.pipeline import execute_publish
1110
+
1111
+ docs_dir = tmp_path / "docs"
1112
+ docs_dir.mkdir()
1113
+ page = _make_page_node("My Page", tmp_path, docs_dir)
1114
+ action = PageAction(node=page, title="My Page", action="create", parent_id="ROOT")
1115
+
1116
+ client = _make_execute_client()
1117
+
1118
+ execute_publish(
1119
+ [action], client, space_id="~42", docs_dir=docs_dir, root_page_id="ROOT",
1120
+ )
1121
+
1122
+ client.stamp_managed.assert_called_once()
1123
+
1124
+ def test_report_str_shows_pruned_count(self) -> None:
1125
+ from mkdocs_to_confluence.publisher.pipeline import PublishReport
1126
+
1127
+ r = PublishReport(created=2, updated=1, skipped=0, pruned=3)
1128
+ out = str(r)
1129
+ assert "3 orphaned page(s) deleted" in out
1130
+
1131
+ def test_report_str_omits_pruned_when_zero(self) -> None:
1132
+ from mkdocs_to_confluence.publisher.pipeline import PublishReport
1133
+
1134
+ r = PublishReport(created=2, updated=1, skipped=0, pruned=0)
1135
+ out = str(r)
1136
+ assert "Pruned" not in out