markdown-to-confluence 0.3.3__tar.gz → 0.3.4__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 (35) hide show
  1. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/PKG-INFO +10 -3
  2. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/README.md +9 -2
  3. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/PKG-INFO +10 -3
  4. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/SOURCES.txt +2 -0
  5. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/__init__.py +1 -1
  6. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/__main__.py +6 -5
  7. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/api.py +104 -33
  8. markdown_to_confluence-0.3.4/md2conf/application.py +208 -0
  9. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/converter.py +33 -24
  10. markdown_to_confluence-0.3.4/md2conf/local.py +132 -0
  11. markdown_to_confluence-0.3.4/md2conf/metadata.py +42 -0
  12. markdown_to_confluence-0.3.4/md2conf/processor.py +218 -0
  13. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/tests/test_conversion.py +1 -1
  14. markdown_to_confluence-0.3.4/tests/test_processor.py +104 -0
  15. markdown_to_confluence-0.3.3/md2conf/application.py +0 -295
  16. markdown_to_confluence-0.3.3/md2conf/processor.py +0 -148
  17. markdown_to_confluence-0.3.3/tests/test_processor.py +0 -66
  18. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/LICENSE +0 -0
  19. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
  20. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/entry_points.txt +0 -0
  21. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/requires.txt +0 -0
  22. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/top_level.txt +0 -0
  23. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/markdown_to_confluence.egg-info/zip-safe +0 -0
  24. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/emoji.py +0 -0
  25. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/entities.dtd +0 -0
  26. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/matcher.py +0 -0
  27. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/mermaid.py +0 -0
  28. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/properties.py +0 -0
  29. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/puppeteer-config.json +0 -0
  30. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/md2conf/py.typed +0 -0
  31. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/pyproject.toml +0 -0
  32. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/setup.cfg +0 -0
  33. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/setup.py +0 -0
  34. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/tests/test_matcher.py +0 -0
  35. {markdown_to_confluence-0.3.3 → markdown_to_confluence-0.3.4}/tests/test_mermaid.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -62,13 +62,13 @@ Whenever possible, the implementation uses [Confluence REST API v2](https://deve
62
62
 
63
63
  ## Installation
64
64
 
65
- Install the core package from PyPI:
65
+ **Required.** Install the core package from [PyPI](https://pypi.org/project/markdown-to-confluence/):
66
66
 
67
67
  ```sh
68
68
  pip install markdown-to-confluence
69
69
  ```
70
70
 
71
- Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
71
+ **Optional.** Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
72
72
 
73
73
  ```sh
74
74
  npm install -g @mermaid-js/mermaid-cli
@@ -222,6 +222,13 @@ Files that don't have the extension `*.md` are skipped automatically. Hidden dir
222
222
 
223
223
  If a matching Confluence page already exists for a Markdown file, the page title in Confluence is left unchanged.
224
224
 
225
+ ### Converting diagrams
226
+
227
+ You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
228
+
229
+ 1. Pre-render into an image. The code block is interpreted by and converted into a PNG or SVG image with the Mermaid diagram utility [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). The generated image is then uploaded to Confluence as an attachment to the page. This is the approach we use and support.
230
+ 2. Render on demand. The code block is transformed into a [diagram macro](https://atlasauthority.atlassian.net/wiki/spaces/MARKDOWNCLOUD/pages/2946826241/Diagram+Macro), which is processed by Confluence. You need a [Confluence plugin](https://marketplace.atlassian.com/apps/1211438/markdown-html-plantuml-latex-diagrams-open-api-mermaid) to turn macro definitions into images when a Confluence page is visited. This is a contributed feature. As authors of *md2conf*, we don't endorse or support any particular Confluence plugin.
231
+
225
232
  ### Running the tool
226
233
 
227
234
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
@@ -28,13 +28,13 @@ Whenever possible, the implementation uses [Confluence REST API v2](https://deve
28
28
 
29
29
  ## Installation
30
30
 
31
- Install the core package from PyPI:
31
+ **Required.** Install the core package from [PyPI](https://pypi.org/project/markdown-to-confluence/):
32
32
 
33
33
  ```sh
34
34
  pip install markdown-to-confluence
35
35
  ```
36
36
 
37
- Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
37
+ **Optional.** Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
38
38
 
39
39
  ```sh
40
40
  npm install -g @mermaid-js/mermaid-cli
@@ -188,6 +188,13 @@ Files that don't have the extension `*.md` are skipped automatically. Hidden dir
188
188
 
189
189
  If a matching Confluence page already exists for a Markdown file, the page title in Confluence is left unchanged.
190
190
 
191
+ ### Converting diagrams
192
+
193
+ You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
194
+
195
+ 1. Pre-render into an image. The code block is interpreted by and converted into a PNG or SVG image with the Mermaid diagram utility [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). The generated image is then uploaded to Confluence as an attachment to the page. This is the approach we use and support.
196
+ 2. Render on demand. The code block is transformed into a [diagram macro](https://atlasauthority.atlassian.net/wiki/spaces/MARKDOWNCLOUD/pages/2946826241/Diagram+Macro), which is processed by Confluence. You need a [Confluence plugin](https://marketplace.atlassian.com/apps/1211438/markdown-html-plantuml-latex-diagrams-open-api-mermaid) to turn macro definitions into images when a Confluence page is visited. This is a contributed feature. As authors of *md2conf*, we don't endorse or support any particular Confluence plugin.
197
+
191
198
  ### Running the tool
192
199
 
193
200
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -62,13 +62,13 @@ Whenever possible, the implementation uses [Confluence REST API v2](https://deve
62
62
 
63
63
  ## Installation
64
64
 
65
- Install the core package from PyPI:
65
+ **Required.** Install the core package from [PyPI](https://pypi.org/project/markdown-to-confluence/):
66
66
 
67
67
  ```sh
68
68
  pip install markdown-to-confluence
69
69
  ```
70
70
 
71
- Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
71
+ **Optional.** Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
72
72
 
73
73
  ```sh
74
74
  npm install -g @mermaid-js/mermaid-cli
@@ -222,6 +222,13 @@ Files that don't have the extension `*.md` are skipped automatically. Hidden dir
222
222
 
223
223
  If a matching Confluence page already exists for a Markdown file, the page title in Confluence is left unchanged.
224
224
 
225
+ ### Converting diagrams
226
+
227
+ You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
228
+
229
+ 1. Pre-render into an image. The code block is interpreted by and converted into a PNG or SVG image with the Mermaid diagram utility [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). The generated image is then uploaded to Confluence as an attachment to the page. This is the approach we use and support.
230
+ 2. Render on demand. The code block is transformed into a [diagram macro](https://atlasauthority.atlassian.net/wiki/spaces/MARKDOWNCLOUD/pages/2946826241/Diagram+Macro), which is processed by Confluence. You need a [Confluence plugin](https://marketplace.atlassian.com/apps/1211438/markdown-html-plantuml-latex-diagrams-open-api-mermaid) to turn macro definitions into images when a Confluence page is visited. This is a contributed feature. As authors of *md2conf*, we don't endorse or support any particular Confluence plugin.
231
+
225
232
  ### Running the tool
226
233
 
227
234
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
@@ -17,8 +17,10 @@ md2conf/application.py
17
17
  md2conf/converter.py
18
18
  md2conf/emoji.py
19
19
  md2conf/entities.dtd
20
+ md2conf/local.py
20
21
  md2conf/matcher.py
21
22
  md2conf/mermaid.py
23
+ md2conf/metadata.py
22
24
  md2conf/processor.py
23
25
  md2conf/properties.py
24
26
  md2conf/puppeteer-config.json
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.3.3"
8
+ __version__ = "0.3.4"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
@@ -22,8 +22,9 @@ import requests
22
22
  from . import __version__
23
23
  from .api import ConfluenceAPI
24
24
  from .application import Application
25
- from .converter import ConfluenceDocumentOptions, ConfluenceSiteMetadata
26
- from .processor import Processor
25
+ from .converter import ConfluenceDocumentOptions, ConfluencePageID
26
+ from .local import LocalConverter
27
+ from .metadata import ConfluenceSiteMetadata
27
28
  from .properties import (
28
29
  ArgumentError,
29
30
  ConfluenceConnectionProperties,
@@ -199,7 +200,7 @@ def main() -> None:
199
200
  heading_anchors=args.heading_anchors,
200
201
  ignore_invalid_url=args.ignore_invalid_url,
201
202
  generated_by=args.generated_by,
202
- root_page_id=args.root_page,
203
+ root_page_id=ConfluencePageID(args.root_page) if args.root_page else None,
203
204
  keep_hierarchy=args.keep_hierarchy,
204
205
  render_mermaid=args.render_mermaid,
205
206
  diagram_output_format=args.diagram_output_format,
@@ -219,7 +220,7 @@ def main() -> None:
219
220
  base_path=site_properties.base_path,
220
221
  space_key=site_properties.space_key,
221
222
  )
222
- Processor(options, site_metadata).process(args.mdpath)
223
+ LocalConverter(options, site_metadata).process(args.mdpath)
223
224
  else:
224
225
  try:
225
226
  properties = ConfluenceConnectionProperties(
@@ -237,7 +238,7 @@ def main() -> None:
237
238
  Application(
238
239
  api,
239
240
  options,
240
- ).synchronize(args.mdpath)
241
+ ).process(args.mdpath)
241
242
  except requests.exceptions.HTTPError as err:
242
243
  logging.error(err)
243
244
 
@@ -7,6 +7,7 @@ Copyright 2022-2025, Levente Hunyadi
7
7
  """
8
8
 
9
9
  import enum
10
+ import functools
10
11
  import io
11
12
  import json
12
13
  import logging
@@ -21,6 +22,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
21
22
  import requests
22
23
 
23
24
  from .converter import ParseError, sanitize_confluence
25
+ from .metadata import ConfluenceSiteMetadata
24
26
  from .properties import (
25
27
  ArgumentError,
26
28
  ConfluenceConnectionProperties,
@@ -76,7 +78,7 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
76
78
  LOGGER = logging.getLogger(__name__)
77
79
 
78
80
 
79
- @dataclass
81
+ @dataclass(frozen=True)
80
82
  class ConfluenceAttachment:
81
83
  id: str
82
84
  media_type: str
@@ -84,14 +86,18 @@ class ConfluenceAttachment:
84
86
  comment: str
85
87
 
86
88
 
87
- @dataclass
88
- class ConfluencePage:
89
+ @dataclass(frozen=True)
90
+ class ConfluencePageMetadata:
89
91
  id: str
90
92
  space_id: str
91
93
  parent_id: str
92
94
  parent_type: Optional[ConfluencePageParentContentType]
93
95
  title: str
94
96
  version: int
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ConfluencePage(ConfluencePageMetadata):
95
101
  content: str
96
102
 
97
103
 
@@ -136,10 +142,12 @@ class ConfluenceAPI:
136
142
 
137
143
 
138
144
  class ConfluenceSession:
145
+ """
146
+ Information about an open session to a Confluence server.
147
+ """
148
+
139
149
  session: requests.Session
140
- domain: str
141
- base_path: str
142
- space_key: Optional[str]
150
+ site: ConfluenceSiteMetadata
143
151
 
144
152
  _space_id_to_key: dict[str, str]
145
153
  _space_key_to_id: dict[str, str]
@@ -152,9 +160,7 @@ class ConfluenceSession:
152
160
  space_key: Optional[str] = None,
153
161
  ) -> None:
154
162
  self.session = session
155
- self.domain = domain
156
- self.base_path = base_path
157
- self.space_key = space_key
163
+ self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
158
164
 
159
165
  self._space_id_to_key = {}
160
166
  self._space_key_to_id = {}
@@ -178,7 +184,9 @@ class ConfluenceSession:
178
184
  :returns: A full URL.
179
185
  """
180
186
 
181
- base_url = f"https://{self.domain}{self.base_path}{version.value}{path}"
187
+ base_url = (
188
+ f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
189
+ )
182
190
  return build_url(base_url, query)
183
191
 
184
192
  def _invoke(
@@ -251,6 +259,29 @@ class ConfluenceSession:
251
259
 
252
260
  return id
253
261
 
262
+ def get_space_id(
263
+ self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
264
+ ) -> Optional[str]:
265
+ """
266
+ Coalesce a space ID or space key into a space ID, accounting for site default.
267
+
268
+ :param space_id: A Confluence space ID.
269
+ :param space_key: A Confluence space key.
270
+ """
271
+
272
+ if space_id is not None and space_key is not None:
273
+ raise ConfluenceError("either space ID or space key is required; not both")
274
+
275
+ if space_id is not None:
276
+ return space_id
277
+
278
+ space_key = space_key or self.site.space_key
279
+ if space_key is not None:
280
+ return self.space_key_to_id(space_key)
281
+
282
+ # space ID and key are unset, and no default space is configured
283
+ return None
284
+
254
285
  def get_attachment_by_name(
255
286
  self, page_id: str, filename: str
256
287
  ) -> ConfluenceAttachment:
@@ -393,6 +424,7 @@ class ConfluenceSession:
393
424
  self,
394
425
  title: str,
395
426
  *,
427
+ space_id: Optional[str] = None,
396
428
  space_key: Optional[str] = None,
397
429
  ) -> str:
398
430
  """
@@ -408,9 +440,9 @@ class ConfluenceSession:
408
440
  query = {
409
441
  "title": title,
410
442
  }
411
- coalesced_space_key = space_key or self.space_key
412
- if coalesced_space_key is not None:
413
- query["space-id"] = self.space_key_to_id(coalesced_space_key)
443
+ space_id = self.get_space_id(space_id=space_id, space_key=space_key)
444
+ if space_id is not None:
445
+ query["space-id"] = space_id
414
446
 
415
447
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
416
448
  payload = typing.cast(dict[str, JsonType], payload)
@@ -425,10 +457,10 @@ class ConfluenceSession:
425
457
 
426
458
  def get_page(self, page_id: str) -> ConfluencePage:
427
459
  """
428
- Retrieve Confluence wiki page details.
460
+ Retrieve Confluence wiki page details and content.
429
461
 
430
462
  :param page_id: The Confluence page ID.
431
- :returns: Confluence page info.
463
+ :returns: Confluence page info and content.
432
464
  """
433
465
 
434
466
  path = f"/pages/{page_id}"
@@ -453,6 +485,33 @@ class ConfluenceSession:
453
485
  content=typing.cast(str, storage["value"]),
454
486
  )
455
487
 
488
+ @functools.cache
489
+ def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata:
490
+ """
491
+ Retrieve Confluence wiki page details.
492
+
493
+ :param page_id: The Confluence page ID.
494
+ :returns: Confluence page info.
495
+ """
496
+
497
+ path = f"/pages/{page_id}"
498
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path)
499
+ data = typing.cast(dict[str, JsonType], payload)
500
+ version = typing.cast(dict[str, JsonType], data["version"])
501
+
502
+ return ConfluencePageMetadata(
503
+ id=page_id,
504
+ space_id=typing.cast(str, data["spaceId"]),
505
+ parent_id=typing.cast(str, data["parentId"]),
506
+ parent_type=(
507
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
508
+ if data["parentType"] is not None
509
+ else None
510
+ ),
511
+ title=typing.cast(str, data["title"]),
512
+ version=typing.cast(int, version["number"]),
513
+ )
514
+
456
515
  def get_page_version(self, page_id: str) -> int:
457
516
  """
458
517
  Retrieve a Confluence wiki page version.
@@ -507,26 +566,21 @@ class ConfluenceSession:
507
566
 
508
567
  def create_page(
509
568
  self,
510
- parent_page_id: str,
569
+ parent_id: str,
511
570
  title: str,
512
571
  new_content: str,
513
- *,
514
- space_key: Optional[str] = None,
515
572
  ) -> ConfluencePage:
516
573
  """
517
574
  Create a new page via Confluence API.
518
575
  """
519
576
 
520
- coalesced_space_key = space_key or self.space_key
521
- if coalesced_space_key is None:
522
- raise ArgumentError("Confluence space key required for creating a new page")
523
-
577
+ parent_page = self.get_page_metadata(parent_id)
524
578
  path = "/pages/"
525
579
  query = {
526
- "spaceId": self.space_key_to_id(coalesced_space_key),
580
+ "spaceId": parent_page.space_id,
527
581
  "status": "current",
528
582
  "title": title,
529
- "parentId": parent_page_id,
583
+ "parentId": parent_id,
530
584
  "body": {"storage": {"value": new_content, "representation": "storage"}},
531
585
  }
532
586
 
@@ -584,13 +638,24 @@ class ConfluenceSession:
584
638
  response.raise_for_status()
585
639
 
586
640
  def page_exists(
587
- self, title: str, *, space_key: Optional[str] = None
641
+ self,
642
+ title: str,
643
+ *,
644
+ space_id: Optional[str] = None,
645
+ space_key: Optional[str] = None,
588
646
  ) -> Optional[str]:
647
+ """
648
+ Check if a Confluence page exists with the given title.
649
+
650
+ :param title: Page title. Pages in the same Confluence space must have a unique title.
651
+ :param space_key: Identifies the Confluence space.
652
+ """
653
+
654
+ space_id = self.get_space_id(space_id=space_id, space_key=space_key)
589
655
  path = "/pages"
590
- coalesced_space_key = space_key or self.space_key
591
656
  query = {"title": title}
592
- if coalesced_space_key is not None:
593
- query["space-id"] = self.space_key_to_id(coalesced_space_key)
657
+ if space_id is not None:
658
+ query["space-id"] = space_id
594
659
 
595
660
  LOGGER.info("Checking if page exists with title: %s", title)
596
661
 
@@ -609,14 +674,20 @@ class ConfluenceSession:
609
674
  else:
610
675
  return None
611
676
 
612
- def get_or_create_page(
613
- self, title: str, parent_id: str, *, space_key: Optional[str] = None
614
- ) -> ConfluencePage:
615
- page_id = self.page_exists(title)
677
+ def get_or_create_page(self, title: str, parent_id: str) -> ConfluencePage:
678
+ """
679
+ Find a page with the given title, or create a new page if no such page exists.
680
+
681
+ :param title: Page title. Pages in the same Confluence space must have a unique title.
682
+ :param parent_id: Identifies the parent page for a new child page.
683
+ """
684
+
685
+ parent_page = self.get_page_metadata(parent_id)
686
+ page_id = self.page_exists(title, space_id=parent_page.space_id)
616
687
 
617
688
  if page_id is not None:
618
689
  LOGGER.debug("Retrieving existing page: %s", page_id)
619
690
  return self.get_page(page_id)
620
691
  else:
621
692
  LOGGER.debug("Creating new page with title: %s", title)
622
- return self.create_page(parent_id, title, "", space_key=space_key)
693
+ return self.create_page(parent_id, title, "")
@@ -0,0 +1,208 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2025, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ import hashlib
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .api import ConfluencePage, ConfluenceSession
15
+ from .converter import (
16
+ ConfluenceDocument,
17
+ ConfluenceDocumentOptions,
18
+ ConfluencePageID,
19
+ attachment_name,
20
+ extract_frontmatter_title,
21
+ extract_qualified_id,
22
+ )
23
+ from .metadata import ConfluencePageMetadata
24
+ from .processor import Converter, Processor, ProcessorFactory
25
+ from .properties import PageError
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+
29
+
30
+ class SynchronizingProcessor(Processor):
31
+ """
32
+ Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
33
+ """
34
+
35
+ api: ConfluenceSession
36
+
37
+ def __init__(
38
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path
39
+ ) -> None:
40
+ """
41
+ Initializes a new processor instance.
42
+
43
+ :param api: Holds information about an open session to a Confluence server.
44
+ :param options: Options that control the generated page content.
45
+ :param root_dir: File system directory that acts as topmost root node.
46
+ """
47
+
48
+ super().__init__(options, api.site, root_dir)
49
+ self.api = api
50
+
51
+ def _get_or_create_page(
52
+ self,
53
+ absolute_path: Path,
54
+ parent_id: Optional[ConfluencePageID],
55
+ *,
56
+ title: Optional[str] = None,
57
+ ) -> ConfluencePageMetadata:
58
+ """
59
+ Creates a new Confluence page if no page is linked in the Markdown document.
60
+ """
61
+
62
+ # parse file
63
+ with open(absolute_path, "r", encoding="utf-8") as f:
64
+ text = f.read()
65
+
66
+ qualified_id, text = extract_qualified_id(text)
67
+
68
+ overwrite = False
69
+ if qualified_id is None:
70
+ # create new Confluence page
71
+ if parent_id is None:
72
+ raise PageError(
73
+ f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
74
+ )
75
+
76
+ # assign title from front-matter if present
77
+ if title is None:
78
+ title, _ = extract_frontmatter_title(text)
79
+
80
+ # use file name (without extension) and path hash if no title is supplied
81
+ if title is None:
82
+ overwrite = True
83
+ relative_path = absolute_path.relative_to(self.root_dir)
84
+ hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
85
+ digest = "".join(f"{c:x}" for c in hash.digest())
86
+ title = f"{absolute_path.stem} [{digest}]"
87
+
88
+ confluence_page = self._create_page(absolute_path, text, title, parent_id)
89
+ else:
90
+ # look up existing Confluence page
91
+ confluence_page = self.api.get_page(qualified_id.page_id)
92
+
93
+ space_key = (
94
+ self.api.space_id_to_key(confluence_page.space_id)
95
+ if confluence_page.space_id
96
+ else self.site.space_key
97
+ )
98
+
99
+ return ConfluencePageMetadata(
100
+ page_id=confluence_page.id,
101
+ space_key=space_key,
102
+ title=confluence_page.title,
103
+ overwrite=overwrite,
104
+ )
105
+
106
+ def _create_page(
107
+ self,
108
+ absolute_path: Path,
109
+ document: str,
110
+ title: str,
111
+ parent_id: ConfluencePageID,
112
+ ) -> ConfluencePage:
113
+ """
114
+ Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
115
+ """
116
+
117
+ confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
118
+ self._update_markdown(
119
+ absolute_path,
120
+ document,
121
+ confluence_page.id,
122
+ self.api.space_id_to_key(confluence_page.space_id),
123
+ )
124
+ return confluence_page
125
+
126
+ def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
127
+ """
128
+ Saves a new version of a Confluence document.
129
+
130
+ Invokes Confluence REST API to persist the new version.
131
+ """
132
+
133
+ base_path = path.parent
134
+ for image in document.images:
135
+ self.api.upload_attachment(
136
+ document.id.page_id,
137
+ attachment_name(image),
138
+ attachment_path=base_path / image,
139
+ )
140
+
141
+ for name, data in document.embedded_images.items():
142
+ self.api.upload_attachment(
143
+ document.id.page_id,
144
+ name,
145
+ raw_data=data,
146
+ )
147
+
148
+ content = document.xhtml()
149
+
150
+ # leave title as it is for existing pages, update title for pages with randomly assigned title
151
+ title = document.title if self.page_metadata[path].overwrite else None
152
+
153
+ LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
154
+ self.api.update_page(document.id.page_id, content, title=title)
155
+
156
+ def _update_markdown(
157
+ self,
158
+ path: Path,
159
+ document: str,
160
+ page_id: str,
161
+ space_key: Optional[str],
162
+ ) -> None:
163
+ """
164
+ Writes the Confluence page ID and space key at the beginning of the Markdown file.
165
+ """
166
+
167
+ content: list[str] = []
168
+
169
+ # check if the file has frontmatter
170
+ index = 0
171
+ if document.startswith("---\n"):
172
+ index = document.find("\n---\n", 4) + 4
173
+
174
+ # insert the Confluence keys after the frontmatter
175
+ content.append(document[:index])
176
+
177
+ content.append(f"<!-- confluence-page-id: {page_id} -->")
178
+ if space_key:
179
+ content.append(f"<!-- confluence-space-key: {space_key} -->")
180
+
181
+ content.append(document[index:])
182
+
183
+ with open(path, "w", encoding="utf-8") as file:
184
+ file.write("\n".join(content))
185
+
186
+
187
+ class SynchronizingProcessorFactory(ProcessorFactory):
188
+ api: ConfluenceSession
189
+
190
+ def __init__(
191
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions
192
+ ) -> None:
193
+ super().__init__(options, api.site)
194
+ self.api = api
195
+
196
+ def create(self, root_dir: Path) -> Processor:
197
+ return SynchronizingProcessor(self.api, self.options, root_dir)
198
+
199
+
200
+ class Application(Converter):
201
+ """
202
+ The entry point for Markdown to Confluence conversion.
203
+ """
204
+
205
+ def __init__(
206
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions
207
+ ) -> None:
208
+ super().__init__(SynchronizingProcessorFactory(api, options))