markdown-to-confluence 0.3.3__py3-none-any.whl → 0.3.4__py3-none-any.whl

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.
@@ -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:
@@ -0,0 +1,22 @@
1
+ markdown_to_confluence-0.3.4.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
+ md2conf/__init__.py,sha256=9gI6OYCv9-54FzxjNHLOH09H5quUDEMWq9pdbhnwoXM,402
3
+ md2conf/__main__.py,sha256=bFcfmSnTWeuhmDm7bJ3jJabZ2S8W9biuAP6_R-Cc9As,8034
4
+ md2conf/api.py,sha256=ZIYoBXclLbzrrQ_oFRllsTEnQIMbxqd9OD80-AC5qM0,22769
5
+ md2conf/application.py,sha256=eIVeAGUzfdIq1uYLYpTg30UNSq-YcUIY-OgKKK3M4E4,6436
6
+ md2conf/converter.py,sha256=2Sgq1WQd-dCtrdTVrBwhowPC8PmubMNCH1aAcRwntjs,39404
7
+ md2conf/emoji.py,sha256=48QJtOD0F3Be1laYLvAOwe0GxrJS-vcfjtCdiBsNcAc,1960
8
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
9
+ md2conf/local.py,sha256=AOuwyvPOXrRRPGOTDeoVYkMPJ9MI2zqRGAvHuY35wy4,3884
10
+ md2conf/matcher.py,sha256=FgMFPvGiOqGezCs8OyerfsVo-iIHFoI6LRMzdcjM5UY,3693
11
+ md2conf/mermaid.py,sha256=un_KHBDpG5Zad_QD3HN1uBwUxp4I-HVJYhNKbH7KwcA,2312
12
+ md2conf/metadata.py,sha256=9BtNRsICbKzPTs63P70XekNARePdW1DtdKNJqXh2ZFM,1013
13
+ md2conf/processor.py,sha256=Ko_3WqLK6jM-bEN7OD9Vc3g3vhSjRYawz3fG6uoUsXc,6733
14
+ md2conf/properties.py,sha256=TOCXLdTfYkKjRwZaMgvXw0mNCI4opEUwpBXro2Kv2B4,2467
15
+ md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
16
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ markdown_to_confluence-0.3.4.dist-info/METADATA,sha256=PUtJXudDooVfwOzVtohxweWHMjgDv5CIrDvyqiJ0tlg,17745
18
+ markdown_to_confluence-0.3.4.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
19
+ markdown_to_confluence-0.3.4.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
20
+ markdown_to_confluence-0.3.4.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
21
+ markdown_to_confluence-0.3.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
22
+ markdown_to_confluence-0.3.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.3.1)
2
+ Generator: setuptools (80.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
md2conf/__init__.py CHANGED
@@ -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"
md2conf/__main__.py CHANGED
@@ -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
 
md2conf/api.py CHANGED
@@ -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, "")
md2conf/application.py CHANGED
@@ -8,7 +8,6 @@ Copyright 2022-2025, Levente Hunyadi
8
8
 
9
9
  import hashlib
10
10
  import logging
11
- import os
12
11
  from pathlib import Path
13
12
  from typing import Optional
14
13
 
@@ -16,167 +15,43 @@ from .api import ConfluencePage, ConfluenceSession
16
15
  from .converter import (
17
16
  ConfluenceDocument,
18
17
  ConfluenceDocumentOptions,
19
- ConfluencePageMetadata,
20
- ConfluenceQualifiedID,
21
- ConfluenceSiteMetadata,
18
+ ConfluencePageID,
22
19
  attachment_name,
23
20
  extract_frontmatter_title,
24
21
  extract_qualified_id,
25
- read_qualified_id,
26
22
  )
27
- from .matcher import Matcher, MatcherOptions
28
- from .properties import ArgumentError, PageError
23
+ from .metadata import ConfluencePageMetadata
24
+ from .processor import Converter, Processor, ProcessorFactory
25
+ from .properties import PageError
29
26
 
30
27
  LOGGER = logging.getLogger(__name__)
31
28
 
32
29
 
33
- class Application:
34
- "The entry point for Markdown to Confluence conversion."
30
+ class SynchronizingProcessor(Processor):
31
+ """
32
+ Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
33
+ """
35
34
 
36
35
  api: ConfluenceSession
37
- options: ConfluenceDocumentOptions
38
36
 
39
37
  def __init__(
40
- self, api: ConfluenceSession, options: ConfluenceDocumentOptions
41
- ) -> None:
42
- self.api = api
43
- self.options = options
44
-
45
- def synchronize(self, path: Path) -> None:
46
- "Synchronizes a single Markdown page or a directory of Markdown pages."
47
-
48
- path = path.resolve(True)
49
- if path.is_dir():
50
- self.synchronize_directory(path)
51
- elif path.is_file():
52
- self.synchronize_page(path)
53
- else:
54
- raise ArgumentError(f"expected: valid file or directory path; got: {path}")
55
-
56
- def synchronize_page(
57
- self, page_path: Path, root_dir: Optional[Path] = None
58
- ) -> None:
59
- "Synchronizes a single Markdown page with Confluence."
60
-
61
- page_path = page_path.resolve(True)
62
- if root_dir is None:
63
- root_dir = page_path.parent
64
- else:
65
- root_dir = root_dir.resolve(True)
66
-
67
- self._synchronize_page(page_path, root_dir, {})
68
-
69
- def synchronize_directory(
70
- self, local_dir: Path, root_dir: Optional[Path] = None
71
- ) -> None:
72
- "Synchronizes a directory of Markdown pages with Confluence."
73
-
74
- local_dir = local_dir.resolve(True)
75
- if root_dir is None:
76
- root_dir = local_dir
77
- else:
78
- root_dir = root_dir.resolve(True)
79
-
80
- LOGGER.info("Synchronizing directory: %s", local_dir)
81
-
82
- # Step 1: build index of all page metadata
83
- page_metadata: dict[Path, ConfluencePageMetadata] = {}
84
- root_id = (
85
- ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
86
- if self.options.root_page_id
87
- else None
88
- )
89
- self._index_directory(local_dir, root_dir, root_id, page_metadata)
90
- LOGGER.info("Indexed %d page(s)", len(page_metadata))
91
-
92
- # Step 2: convert each page
93
- for page_path in page_metadata.keys():
94
- self._synchronize_page(page_path, root_dir, page_metadata)
95
-
96
- def _synchronize_page(
97
- self,
98
- page_path: Path,
99
- root_dir: Path,
100
- page_metadata: dict[Path, ConfluencePageMetadata],
101
- ) -> None:
102
- base_path = page_path.parent
103
-
104
- LOGGER.info("Synchronizing page: %s", page_path)
105
- site_metadata = ConfluenceSiteMetadata(
106
- domain=self.api.domain,
107
- base_path=self.api.base_path,
108
- space_key=self.api.space_key,
109
- )
110
-
111
- document = ConfluenceDocument.create(
112
- page_path, self.options, root_dir, site_metadata, page_metadata
113
- )
114
- self._update_document(document, base_path)
115
-
116
- def _index_directory(
117
- self,
118
- local_dir: Path,
119
- root_dir: Path,
120
- root_id: Optional[ConfluenceQualifiedID],
121
- page_metadata: dict[Path, ConfluencePageMetadata],
38
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path
122
39
  ) -> None:
123
- "Indexes Markdown files in a directory recursively."
124
-
125
- LOGGER.info("Indexing directory: %s", local_dir)
126
-
127
- matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
128
-
129
- files: list[Path] = []
130
- directories: list[Path] = []
131
- for entry in os.scandir(local_dir):
132
- if matcher.is_excluded(entry.name, entry.is_dir()):
133
- continue
134
-
135
- if entry.is_file():
136
- files.append(Path(local_dir) / entry.name)
137
- elif entry.is_dir():
138
- directories.append(Path(local_dir) / entry.name)
139
-
140
- # make page act as parent node in Confluence
141
- parent_doc: Optional[Path] = None
142
- if (Path(local_dir) / "index.md") in files:
143
- parent_doc = Path(local_dir) / "index.md"
144
- elif (Path(local_dir) / "README.md") in files:
145
- parent_doc = Path(local_dir) / "README.md"
146
- elif (Path(local_dir) / f"{local_dir.name}.md") in files:
147
- parent_doc = Path(local_dir) / f"{local_dir.name}.md"
148
-
149
- if parent_doc is None and self.options.keep_hierarchy:
150
- parent_doc = Path(local_dir) / "index.md"
151
-
152
- # create a blank page in Confluence for the directory entry
153
- with open(parent_doc, "w"):
154
- pass
155
-
156
- if parent_doc is not None:
157
- files.remove(parent_doc)
158
-
159
- metadata = self._get_or_create_page(parent_doc, root_dir, root_id)
160
- LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
161
- page_metadata[parent_doc] = metadata
162
-
163
- parent_id = read_qualified_id(parent_doc) or root_id
164
- else:
165
- parent_id = root_id
40
+ """
41
+ Initializes a new processor instance.
166
42
 
167
- for doc in files:
168
- metadata = self._get_or_create_page(doc, root_dir, parent_id)
169
- LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
170
- page_metadata[doc] = metadata
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
+ """
171
47
 
172
- for directory in directories:
173
- self._index_directory(directory, root_dir, parent_id, page_metadata)
48
+ super().__init__(options, api.site, root_dir)
49
+ self.api = api
174
50
 
175
51
  def _get_or_create_page(
176
52
  self,
177
53
  absolute_path: Path,
178
- root_dir: Path,
179
- parent_id: Optional[ConfluenceQualifiedID],
54
+ parent_id: Optional[ConfluencePageID],
180
55
  *,
181
56
  title: Optional[str] = None,
182
57
  ) -> ConfluencePageMetadata:
@@ -186,13 +61,13 @@ class Application:
186
61
 
187
62
  # parse file
188
63
  with open(absolute_path, "r", encoding="utf-8") as f:
189
- document = f.read()
64
+ text = f.read()
190
65
 
191
- qualified_id, document = extract_qualified_id(document)
66
+ qualified_id, text = extract_qualified_id(text)
192
67
 
193
- if qualified_id is not None:
194
- confluence_page = self.api.get_page(qualified_id.page_id)
195
- else:
68
+ overwrite = False
69
+ if qualified_id is None:
70
+ # create new Confluence page
196
71
  if parent_id is None:
197
72
  raise PageError(
198
73
  f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
@@ -200,29 +75,32 @@ class Application:
200
75
 
201
76
  # assign title from front-matter if present
202
77
  if title is None:
203
- title, _ = extract_frontmatter_title(document)
78
+ title, _ = extract_frontmatter_title(text)
204
79
 
205
80
  # use file name (without extension) and path hash if no title is supplied
206
81
  if title is None:
207
- relative_path = absolute_path.relative_to(root_dir)
82
+ overwrite = True
83
+ relative_path = absolute_path.relative_to(self.root_dir)
208
84
  hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
209
85
  digest = "".join(f"{c:x}" for c in hash.digest())
210
86
  title = f"{absolute_path.stem} [{digest}]"
211
87
 
212
- confluence_page = self._create_page(
213
- absolute_path, document, title, parent_id
214
- )
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)
215
92
 
216
93
  space_key = (
217
94
  self.api.space_id_to_key(confluence_page.space_id)
218
95
  if confluence_page.space_id
219
- else self.api.space_key
96
+ else self.site.space_key
220
97
  )
221
98
 
222
99
  return ConfluencePageMetadata(
223
100
  page_id=confluence_page.id,
224
101
  space_key=space_key,
225
- title=confluence_page.title or "",
102
+ title=confluence_page.title,
103
+ overwrite=overwrite,
226
104
  )
227
105
 
228
106
  def _create_page(
@@ -230,13 +108,13 @@ class Application:
230
108
  absolute_path: Path,
231
109
  document: str,
232
110
  title: str,
233
- parent_id: ConfluenceQualifiedID,
111
+ parent_id: ConfluencePageID,
234
112
  ) -> ConfluencePage:
235
- "Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet."
113
+ """
114
+ Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
115
+ """
236
116
 
237
- confluence_page = self.api.get_or_create_page(
238
- title, parent_id.page_id, space_key=parent_id.space_key
239
- )
117
+ confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
240
118
  self._update_markdown(
241
119
  absolute_path,
242
120
  document,
@@ -245,9 +123,14 @@ class Application:
245
123
  )
246
124
  return confluence_page
247
125
 
248
- def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
249
- "Saves a new version of a Confluence document."
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
+ """
250
132
 
133
+ base_path = path.parent
251
134
  for image in document.images:
252
135
  self.api.upload_attachment(
253
136
  document.id.page_id,
@@ -263,8 +146,12 @@ class Application:
263
146
  )
264
147
 
265
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
+
266
153
  LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
267
- self.api.update_page(document.id.page_id, content, title=document.title)
154
+ self.api.update_page(document.id.page_id, content, title=title)
268
155
 
269
156
  def _update_markdown(
270
157
  self,
@@ -273,7 +160,9 @@ class Application:
273
160
  page_id: str,
274
161
  space_key: Optional[str],
275
162
  ) -> None:
276
- "Writes the Confluence page ID and space key at the beginning of the Markdown file."
163
+ """
164
+ Writes the Confluence page ID and space key at the beginning of the Markdown file.
165
+ """
277
166
 
278
167
  content: list[str] = []
279
168
 
@@ -293,3 +182,27 @@ class Application:
293
182
 
294
183
  with open(path, "w", encoding="utf-8") as file:
295
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))
md2conf/converter.py CHANGED
@@ -26,6 +26,7 @@ import yaml
26
26
  from lxml.builder import ElementMaker
27
27
 
28
28
  from .mermaid import render_diagram
29
+ from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
29
30
  from .properties import PageError
30
31
 
31
32
  namespaces = {
@@ -142,8 +143,8 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
142
143
 
143
144
  try:
144
145
  return ET.fromstringlist(data, parser=parser)
145
- except ET.XMLSyntaxError as e:
146
- raise ParseError(e)
146
+ except ET.XMLSyntaxError as ex:
147
+ raise ParseError() from ex
147
148
 
148
149
 
149
150
  def elements_from_strings(items: list[str]) -> ET._Element:
@@ -240,20 +241,6 @@ _languages = [
240
241
  ]
241
242
 
242
243
 
243
- @dataclass
244
- class ConfluenceSiteMetadata:
245
- domain: str
246
- base_path: str
247
- space_key: Optional[str]
248
-
249
-
250
- @dataclass
251
- class ConfluencePageMetadata:
252
- page_id: str
253
- space_key: Optional[str]
254
- title: str
255
-
256
-
257
244
  class NodeVisitor:
258
245
  def visit(self, node: ET._Element) -> None:
259
246
  "Recursively visits all descendants of this node."
@@ -974,6 +961,14 @@ def extract_value(pattern: str, text: str) -> tuple[Optional[str], str]:
974
961
  return value, text
975
962
 
976
963
 
964
+ @dataclass
965
+ class ConfluencePageID:
966
+ page_id: str
967
+
968
+ def __init__(self, page_id: str):
969
+ self.page_id = page_id
970
+
971
+
977
972
  @dataclass
978
973
  class ConfluenceQualifiedID:
979
974
  page_id: str
@@ -1048,13 +1043,17 @@ class ConfluenceDocumentOptions:
1048
1043
  ignore_invalid_url: bool = False
1049
1044
  heading_anchors: bool = False
1050
1045
  generated_by: Optional[str] = "This page has been generated with a tool."
1051
- root_page_id: Optional[str] = None
1046
+ root_page_id: Optional[ConfluencePageID] = None
1052
1047
  keep_hierarchy: bool = False
1053
1048
  render_mermaid: bool = False
1054
1049
  diagram_output_format: Literal["png", "svg"] = "png"
1055
1050
  webui_links: bool = False
1056
1051
 
1057
1052
 
1053
+ class ConversionError(RuntimeError):
1054
+ "Raised when a Markdown document cannot be converted to Confluence Storage Format."
1055
+
1056
+
1058
1057
  class ConfluenceDocument:
1059
1058
  id: ConfluenceQualifiedID
1060
1059
  title: Optional[str]
@@ -1107,32 +1106,42 @@ class ConfluenceDocument:
1107
1106
  self.options = options
1108
1107
  self.id = qualified_id
1109
1108
 
1109
+ # extract frontmatter
1110
+ self.title, text = extract_frontmatter_title(text)
1111
+
1110
1112
  # extract 'generated-by' tag text
1111
1113
  generated_by_tag, text = extract_value(
1112
1114
  r"<!--\s+generated-by:\s*(.*)\s+-->", text
1113
1115
  )
1114
1116
 
1115
- # extract frontmatter
1116
- self.title, text = extract_frontmatter_title(text)
1117
-
1118
1117
  # convert to HTML
1119
1118
  html = markdown_to_html(text)
1120
1119
 
1121
1120
  # parse Markdown document
1122
1121
  if self.options.generated_by is not None:
1123
- generated_by = self.options.generated_by
1124
1122
  if generated_by_tag is not None:
1125
- generated_by = generated_by_tag
1123
+ generated_by_text = generated_by_tag
1124
+ else:
1125
+ generated_by_text = self.options.generated_by
1126
+ else:
1127
+ generated_by_text = None
1128
+
1129
+ if generated_by_text is not None:
1130
+ generated_by_html = markdown_to_html(generated_by_text)
1126
1131
 
1127
1132
  content = [
1128
1133
  '<ac:structured-macro ac:name="info" ac:schema-version="1">',
1129
- f"<ac:rich-text-body><p>{generated_by}</p></ac:rich-text-body>",
1134
+ f"<ac:rich-text-body>{generated_by_html}</ac:rich-text-body>",
1130
1135
  "</ac:structured-macro>",
1131
1136
  html,
1132
1137
  ]
1133
1138
  else:
1134
1139
  content = [html]
1135
- self.root = elements_from_strings(content)
1140
+
1141
+ try:
1142
+ self.root = elements_from_strings(content)
1143
+ except ParseError as ex:
1144
+ raise ConversionError(path) from ex
1136
1145
 
1137
1146
  converter = ConfluenceStorageFormatConverter(
1138
1147
  ConfluenceConverterOptions(
md2conf/local.py ADDED
@@ -0,0 +1,132 @@
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
+ import os
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from .converter import (
16
+ ConfluenceDocument,
17
+ ConfluenceDocumentOptions,
18
+ ConfluencePageID,
19
+ ConfluenceQualifiedID,
20
+ extract_qualified_id,
21
+ )
22
+ from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
23
+ from .processor import Converter, Processor, ProcessorFactory
24
+ from .properties import PageError
25
+
26
+ LOGGER = logging.getLogger(__name__)
27
+
28
+
29
+ class LocalProcessor(Processor):
30
+ """
31
+ Transforms a single Markdown page or a directory of Markdown pages into Confluence Storage Format (CSF) documents.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ options: ConfluenceDocumentOptions,
37
+ site: ConfluenceSiteMetadata,
38
+ *,
39
+ out_dir: Optional[Path],
40
+ root_dir: Path,
41
+ ) -> None:
42
+ """
43
+ Initializes a new processor instance.
44
+
45
+ :param options: Options that control the generated page content.
46
+ :param site: Data associated with a Confluence wiki site.
47
+ :param out_dir: File system directory to write generated CSF documents to.
48
+ :param root_dir: File system directory that acts as topmost root node.
49
+ """
50
+
51
+ super().__init__(options, site, root_dir)
52
+ self.out_dir = out_dir or root_dir
53
+
54
+ def _get_or_create_page(
55
+ self,
56
+ absolute_path: Path,
57
+ parent_id: Optional[ConfluencePageID],
58
+ *,
59
+ title: Optional[str] = None,
60
+ ) -> ConfluencePageMetadata:
61
+ """
62
+ Extracts metadata from a Markdown file.
63
+ """
64
+
65
+ # parse file
66
+ with open(absolute_path, "r", encoding="utf-8") as f:
67
+ text = f.read()
68
+
69
+ qualified_id, text = extract_qualified_id(text)
70
+
71
+ if qualified_id is None:
72
+ if parent_id is None:
73
+ raise PageError(
74
+ f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
75
+ )
76
+
77
+ hash = hashlib.md5(text.encode("utf-8"))
78
+ digest = "".join(f"{c:x}" for c in hash.digest())
79
+ LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
80
+ qualified_id = ConfluenceQualifiedID(digest)
81
+
82
+ return ConfluencePageMetadata(
83
+ page_id=qualified_id.page_id,
84
+ space_key=qualified_id.space_key,
85
+ title="",
86
+ overwrite=True,
87
+ )
88
+
89
+ def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
90
+ """
91
+ Saves a new version of a Confluence document.
92
+
93
+ A derived class may invoke Confluence REST API to persist the new version.
94
+ """
95
+
96
+ content = document.xhtml()
97
+ out_path = self.out_dir / path.relative_to(self.root_dir).with_suffix(".csf")
98
+ os.makedirs(out_path.parent, exist_ok=True)
99
+ with open(out_path, "w", encoding="utf-8") as f:
100
+ f.write(content)
101
+
102
+
103
+ class LocalProcessorFactory(ProcessorFactory):
104
+ out_dir: Optional[Path]
105
+
106
+ def __init__(
107
+ self,
108
+ options: ConfluenceDocumentOptions,
109
+ site: ConfluenceSiteMetadata,
110
+ out_dir: Optional[Path] = None,
111
+ ) -> None:
112
+ super().__init__(options, site)
113
+ self.out_dir = out_dir
114
+
115
+ def create(self, root_dir: Path) -> Processor:
116
+ return LocalProcessor(
117
+ self.options, self.site, out_dir=self.out_dir, root_dir=root_dir
118
+ )
119
+
120
+
121
+ class LocalConverter(Converter):
122
+ """
123
+ The entry point for Markdown to Confluence conversion.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ options: ConfluenceDocumentOptions,
129
+ site: ConfluenceSiteMetadata,
130
+ out_dir: Optional[Path] = None,
131
+ ) -> None:
132
+ super().__init__(LocalProcessorFactory(options, site, out_dir))
md2conf/metadata.py ADDED
@@ -0,0 +1,42 @@
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
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+
13
+ @dataclass
14
+ class ConfluenceSiteMetadata:
15
+ """
16
+ Data associated with a Confluence wiki site.
17
+
18
+ :param domain: Confluence organization domain (e.g. `levente-hunyadi.atlassian.net`).
19
+ :param base_path: Base path for Confluence (default: `/wiki/`).
20
+ :param space_key: Confluence space key for new pages (e.g. `~hunyadi` or `INST`).
21
+ """
22
+
23
+ domain: str
24
+ base_path: str
25
+ space_key: Optional[str]
26
+
27
+
28
+ @dataclass
29
+ class ConfluencePageMetadata:
30
+ """
31
+ Data associated with a Confluence page.
32
+
33
+ :param page_id: Confluence page ID.
34
+ :param space_key: Confluence space key.
35
+ :param title: Document title.
36
+ :param overwrite: True if operations are allowed to update document properties (e.g. title).
37
+ """
38
+
39
+ page_id: str
40
+ space_key: Optional[str]
41
+ title: str
42
+ overwrite: bool
md2conf/processor.py CHANGED
@@ -6,101 +6,96 @@ Copyright 2022-2025, Levente Hunyadi
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
- import hashlib
10
9
  import logging
11
10
  import os
11
+ from abc import abstractmethod
12
12
  from pathlib import Path
13
13
  from typing import Optional
14
14
 
15
- from .converter import (
16
- ConfluenceDocument,
17
- ConfluenceDocumentOptions,
18
- ConfluencePageMetadata,
19
- ConfluenceQualifiedID,
20
- ConfluenceSiteMetadata,
21
- extract_qualified_id,
22
- )
15
+ from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
23
16
  from .matcher import Matcher, MatcherOptions
17
+ from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
24
18
  from .properties import ArgumentError
25
19
 
26
20
  LOGGER = logging.getLogger(__name__)
27
21
 
28
22
 
29
23
  class Processor:
24
+ """
25
+ Processes a single Markdown page or a directory of Markdown pages.
26
+ """
27
+
30
28
  options: ConfluenceDocumentOptions
31
- site_metadata: ConfluenceSiteMetadata
29
+ site: ConfluenceSiteMetadata
30
+ root_dir: Path
31
+
32
+ page_metadata: dict[Path, ConfluencePageMetadata]
32
33
 
33
34
  def __init__(
34
- self, options: ConfluenceDocumentOptions, site_metadata: ConfluenceSiteMetadata
35
+ self,
36
+ options: ConfluenceDocumentOptions,
37
+ site: ConfluenceSiteMetadata,
38
+ root_dir: Path,
35
39
  ) -> None:
36
40
  self.options = options
37
- self.site_metadata = site_metadata
38
-
39
- def process(self, path: Path) -> None:
40
- "Processes a single Markdown file or a directory of Markdown files."
41
+ self.site = site
42
+ self.root_dir = root_dir
41
43
 
42
- path = path.resolve(True)
43
- if path.is_dir():
44
- self.process_directory(path)
45
- elif path.is_file():
46
- self.process_page(path)
47
- else:
48
- raise ArgumentError(f"expected: valid file or directory path; got: {path}")
44
+ self.page_metadata = {}
49
45
 
50
- def process_directory(
51
- self, local_dir: Path, root_dir: Optional[Path] = None
52
- ) -> None:
53
- "Recursively scans a directory hierarchy for Markdown files."
46
+ def process_directory(self, local_dir: Path) -> None:
47
+ """
48
+ Recursively scans a directory hierarchy for Markdown files, and processes each, resolving cross-references.
49
+ """
54
50
 
55
51
  local_dir = local_dir.resolve(True)
56
- if root_dir is None:
57
- root_dir = local_dir
58
- else:
59
- root_dir = root_dir.resolve(True)
60
-
61
- LOGGER.info("Synchronizing directory: %s", local_dir)
52
+ LOGGER.info("Processing directory: %s", local_dir)
62
53
 
63
54
  # Step 1: build index of all page metadata
64
- page_metadata: dict[Path, ConfluencePageMetadata] = {}
65
- self._index_directory(local_dir, page_metadata)
66
- LOGGER.info("Indexed %d page(s)", len(page_metadata))
55
+ self._index_directory(local_dir, self.options.root_page_id)
56
+ LOGGER.info("Indexed %d page(s)", len(self.page_metadata))
67
57
 
68
58
  # Step 2: convert each page
69
- for page_path in page_metadata.keys():
70
- self._process_page(page_path, root_dir, page_metadata)
59
+ for page_path in self.page_metadata.keys():
60
+ self._process_page(page_path)
71
61
 
72
- def process_page(self, path: Path, root_dir: Optional[Path] = None) -> None:
73
- "Processes a single Markdown file."
62
+ def process_page(self, path: Path) -> None:
63
+ """
64
+ Processes a single Markdown file.
65
+ """
74
66
 
75
- path = path.resolve(True)
76
- if root_dir is None:
77
- root_dir = path.parent
78
- else:
79
- root_dir = root_dir.resolve(True)
80
-
81
- self._process_page(path, root_dir, {})
82
-
83
- def _process_page(
84
- self,
85
- path: Path,
86
- root_dir: Path,
87
- page_metadata: dict[Path, ConfluencePageMetadata],
88
- ) -> None:
89
- "Processes a single Markdown file."
67
+ LOGGER.info("Processing page: %s", path)
68
+ self._index_page(path, self.options.root_page_id)
69
+ self._process_page(path)
90
70
 
71
+ def _process_page(self, path: Path) -> None:
91
72
  document = ConfluenceDocument.create(
92
- path, self.options, root_dir, self.site_metadata, page_metadata
73
+ path, self.options, self.root_dir, self.site, self.page_metadata
93
74
  )
94
- content = document.xhtml()
95
- with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
96
- f.write(content)
75
+ self._save_document(document, path)
97
76
 
98
- def _index_directory(
77
+ @abstractmethod
78
+ def _get_or_create_page(
99
79
  self,
100
- local_dir: Path,
101
- page_metadata: dict[Path, ConfluencePageMetadata],
80
+ absolute_path: Path,
81
+ parent_id: Optional[ConfluencePageID],
82
+ *,
83
+ title: Optional[str] = None,
84
+ ) -> ConfluencePageMetadata:
85
+ """
86
+ Creates a new Confluence page if no page is linked in the Markdown document.
87
+ """
88
+ ...
89
+
90
+ @abstractmethod
91
+ def _save_document(self, document: ConfluenceDocument, path: Path) -> None: ...
92
+
93
+ def _index_directory(
94
+ self, local_dir: Path, parent_id: Optional[ConfluencePageID]
102
95
  ) -> None:
103
- "Indexes Markdown files in a directory recursively."
96
+ """
97
+ Indexes Markdown files in a directory hierarchy recursively.
98
+ """
104
99
 
105
100
  LOGGER.info("Indexing directory: %s", local_dir)
106
101
 
@@ -117,32 +112,107 @@ class Processor:
117
112
  elif entry.is_dir():
118
113
  directories.append(Path(local_dir) / entry.name)
119
114
 
115
+ # make page act as parent node
116
+ parent_doc: Optional[Path] = None
117
+ if (Path(local_dir) / "index.md") in files:
118
+ parent_doc = Path(local_dir) / "index.md"
119
+ elif (Path(local_dir) / "README.md") in files:
120
+ parent_doc = Path(local_dir) / "README.md"
121
+ elif (Path(local_dir) / f"{local_dir.name}.md") in files:
122
+ parent_doc = Path(local_dir) / f"{local_dir.name}.md"
123
+
124
+ if parent_doc is None and self.options.keep_hierarchy:
125
+ parent_doc = Path(local_dir) / "index.md"
126
+
127
+ # create a blank page for directory entry
128
+ with open(parent_doc, "w"):
129
+ pass
130
+
131
+ if parent_doc is not None:
132
+ if parent_doc in files:
133
+ files.remove(parent_doc)
134
+
135
+ # use latest parent as parent for index page
136
+ metadata = self._get_or_create_page(parent_doc, parent_id)
137
+ LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
138
+ self.page_metadata[parent_doc] = metadata
139
+
140
+ # assign new index page as new parent
141
+ parent_id = ConfluencePageID(metadata.page_id)
142
+
120
143
  for doc in files:
121
- metadata = self._get_page(doc)
122
- LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
123
- page_metadata[doc] = metadata
144
+ self._index_page(doc, parent_id)
124
145
 
125
146
  for directory in directories:
126
- self._index_directory(directory, page_metadata)
127
-
128
- def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
129
- "Extracts metadata from a Markdown file."
130
-
131
- with open(absolute_path, "r", encoding="utf-8") as f:
132
- document = f.read()
133
-
134
- qualified_id, document = extract_qualified_id(document)
135
- if qualified_id is None:
136
- if self.options.root_page_id is not None:
137
- hash = hashlib.md5(document.encode("utf-8"))
138
- digest = "".join(f"{c:x}" for c in hash.digest())
139
- LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
140
- qualified_id = ConfluenceQualifiedID(digest)
141
- else:
142
- raise ArgumentError("required: page ID for local output")
143
-
144
- return ConfluencePageMetadata(
145
- page_id=qualified_id.page_id,
146
- space_key=qualified_id.space_key,
147
- title="",
148
- )
147
+ self._index_directory(directory, parent_id)
148
+
149
+ def _index_page(self, path: Path, parent_id: Optional[ConfluencePageID]) -> None:
150
+ """
151
+ Indexes a single Markdown file.
152
+ """
153
+
154
+ metadata = self._get_or_create_page(path, parent_id)
155
+ LOGGER.debug("Indexed %s with metadata: %s", path, metadata)
156
+ self.page_metadata[path] = metadata
157
+
158
+
159
+ class ProcessorFactory:
160
+ options: ConfluenceDocumentOptions
161
+ site: ConfluenceSiteMetadata
162
+
163
+ def __init__(
164
+ self, options: ConfluenceDocumentOptions, site: ConfluenceSiteMetadata
165
+ ) -> None:
166
+ self.options = options
167
+ self.site = site
168
+
169
+ @abstractmethod
170
+ def create(self, root_dir: Path) -> Processor: ...
171
+
172
+
173
+ class Converter:
174
+ factory: ProcessorFactory
175
+
176
+ def __init__(self, factory: ProcessorFactory) -> None:
177
+ self.factory = factory
178
+
179
+ def process(self, path: Path) -> None:
180
+ """
181
+ Processes a single Markdown file or a directory of Markdown files.
182
+ """
183
+
184
+ path = path.resolve(True)
185
+ if path.is_dir():
186
+ self.process_directory(path)
187
+ elif path.is_file():
188
+ self.process_page(path)
189
+ else:
190
+ raise ArgumentError(f"expected: valid file or directory path; got: {path}")
191
+
192
+ def process_directory(
193
+ self, local_dir: Path, root_dir: Optional[Path] = None
194
+ ) -> None:
195
+ """
196
+ Recursively scans a directory hierarchy for Markdown files, and processes each, resolving cross-references.
197
+ """
198
+
199
+ local_dir = local_dir.resolve(True)
200
+ if root_dir is None:
201
+ root_dir = local_dir
202
+ else:
203
+ root_dir = root_dir.resolve(True)
204
+
205
+ self.factory.create(root_dir).process_directory(local_dir)
206
+
207
+ def process_page(self, path: Path, root_dir: Optional[Path] = None) -> None:
208
+ """
209
+ Processes a single Markdown file.
210
+ """
211
+
212
+ path = path.resolve(True)
213
+ if root_dir is None:
214
+ root_dir = path.parent
215
+ else:
216
+ root_dir = root_dir.resolve(True)
217
+
218
+ self.factory.create(root_dir).process_page(path)
@@ -1,20 +0,0 @@
1
- markdown_to_confluence-0.3.3.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
- md2conf/__init__.py,sha256=NHoSu8tHMVLytWmla4BA_Uzkl-04rV_O8YkkFxUkT_E,402
3
- md2conf/__main__.py,sha256=aTRiXcvoIYMkwCGejL6MUriHXBo3qVP2Acr2I-XzMyg,7947
4
- md2conf/api.py,sha256=S5IB7j48wE9MHSj1jodHYmTE6scSXb80faULW6-5RjU,20376
5
- md2conf/application.py,sha256=FkJ9zYBLwYcCRkd_WiX6JI6nlw4QMETmrOXHeSzCwCE,9735
6
- md2conf/converter.py,sha256=B4Z8afTmhea6nSXhzDVxN55GfMvlY34tGqCLspQ_p5g,38983
7
- md2conf/emoji.py,sha256=48QJtOD0F3Be1laYLvAOwe0GxrJS-vcfjtCdiBsNcAc,1960
8
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
9
- md2conf/matcher.py,sha256=FgMFPvGiOqGezCs8OyerfsVo-iIHFoI6LRMzdcjM5UY,3693
10
- md2conf/mermaid.py,sha256=un_KHBDpG5Zad_QD3HN1uBwUxp4I-HVJYhNKbH7KwcA,2312
11
- md2conf/processor.py,sha256=9jPswgPewh2glLSHdgxyXesGxkcxPVa_h7oUhM9EsA4,4740
12
- md2conf/properties.py,sha256=TOCXLdTfYkKjRwZaMgvXw0mNCI4opEUwpBXro2Kv2B4,2467
13
- md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
14
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- markdown_to_confluence-0.3.3.dist-info/METADATA,sha256=SiOfBvA3jMCn3Hjd_Let9R-DqcMuPG48xP-1x2pg_JI,16495
16
- markdown_to_confluence-0.3.3.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
17
- markdown_to_confluence-0.3.3.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
18
- markdown_to_confluence-0.3.3.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
19
- markdown_to_confluence-0.3.3.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
20
- markdown_to_confluence-0.3.3.dist-info/RECORD,,