markdown-to-confluence 0.4.0__py3-none-any.whl → 0.4.1__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.
md2conf/application.py CHANGED
@@ -10,14 +10,9 @@ import logging
10
10
  from pathlib import Path
11
11
  from typing import Optional
12
12
 
13
- from .api import ConfluenceLabel, ConfluenceSession
14
- from .converter import (
15
- ConfluenceDocument,
16
- ConfluenceDocumentOptions,
17
- ConfluencePageID,
18
- attachment_name,
19
- )
20
- from .extra import override
13
+ from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
14
+ from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID, attachment_name
15
+ from .extra import override, path_relative_to
21
16
  from .metadata import ConfluencePageMetadata
22
17
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
23
18
  from .properties import PageError
@@ -32,9 +27,7 @@ class SynchronizingProcessor(Processor):
32
27
 
33
28
  api: ConfluenceSession
34
29
 
35
- def __init__(
36
- self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path
37
- ) -> None:
30
+ def __init__(self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path) -> None:
38
31
  """
39
32
  Initializes a new processor instance.
40
33
 
@@ -47,9 +40,7 @@ class SynchronizingProcessor(Processor):
47
40
  self.api = api
48
41
 
49
42
  @override
50
- def _synchronize_tree(
51
- self, root: DocumentNode, root_id: Optional[ConfluencePageID]
52
- ) -> None:
43
+ def _synchronize_tree(self, root: DocumentNode, root_id: Optional[ConfluencePageID]) -> None:
53
44
  """
54
45
  Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
55
46
 
@@ -59,14 +50,10 @@ class SynchronizingProcessor(Processor):
59
50
  """
60
51
 
61
52
  if root.page_id is None and root_id is None:
62
- raise PageError(
63
- f"expected: root page ID in options, or explicit page ID in {root.absolute_path}"
64
- )
53
+ raise PageError(f"expected: root page ID in options, or explicit page ID in {root.absolute_path}")
65
54
  elif root.page_id is not None and root_id is not None:
66
55
  if root.page_id != root_id.page_id:
67
- raise PageError(
68
- f"mismatched inferred page ID of {root_id.page_id} and explicit page ID in {root.absolute_path}"
69
- )
56
+ raise PageError(f"mismatched inferred page ID of {root_id.page_id} and explicit page ID in {root.absolute_path}")
70
57
 
71
58
  real_id = root_id
72
59
  elif root_id is not None:
@@ -78,9 +65,7 @@ class SynchronizingProcessor(Processor):
78
65
 
79
66
  self._synchronize_subtree(root, real_id)
80
67
 
81
- def _synchronize_subtree(
82
- self, node: DocumentNode, parent_id: ConfluencePageID
83
- ) -> None:
68
+ def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID) -> None:
84
69
  if node.page_id is not None:
85
70
  # verify if page exists
86
71
  page = self.api.get_page_properties(node.page_id)
@@ -88,6 +73,10 @@ class SynchronizingProcessor(Processor):
88
73
  elif node.title is not None:
89
74
  # look up page by title
90
75
  page = self.api.get_or_create_page(node.title, parent_id.page_id)
76
+
77
+ if page.status is ConfluenceStatus.ARCHIVED:
78
+ raise PageError(f"unable to update archived page with ID {page.id}")
79
+
91
80
  update = True
92
81
  else:
93
82
  # always create a new page
@@ -115,9 +104,7 @@ class SynchronizingProcessor(Processor):
115
104
  self._synchronize_subtree(child_node, ConfluencePageID(page.id))
116
105
 
117
106
  @override
118
- def _update_page(
119
- self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
120
- ) -> None:
107
+ def _update_page(self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path) -> None:
121
108
  """
122
109
  Saves a new version of a Confluence document.
123
110
 
@@ -125,11 +112,11 @@ class SynchronizingProcessor(Processor):
125
112
  """
126
113
 
127
114
  base_path = path.parent
128
- for image in document.images:
115
+ for image_path in document.images:
129
116
  self.api.upload_attachment(
130
117
  page_id.page_id,
131
- attachment_name(image),
132
- attachment_path=base_path / image,
118
+ attachment_name(path_relative_to(image_path, base_path)),
119
+ attachment_path=image_path,
133
120
  )
134
121
 
135
122
  for name, data in document.embedded_images.items():
@@ -145,14 +132,8 @@ class SynchronizingProcessor(Processor):
145
132
  title = None
146
133
  if document.title is not None:
147
134
  meta = self.page_metadata.get(path)
148
- if (
149
- meta is not None
150
- and meta.space_key is not None
151
- and meta.title != document.title
152
- ):
153
- conflicting_page_id = self.api.page_exists(
154
- document.title, space_id=self.api.space_key_to_id(meta.space_key)
155
- )
135
+ if meta is not None and meta.space_key is not None and meta.title != document.title:
136
+ conflicting_page_id = self.api.page_exists(document.title, space_id=self.api.space_key_to_id(meta.space_key))
156
137
  if conflicting_page_id is None:
157
138
  title = document.title
158
139
  else:
@@ -167,12 +148,12 @@ class SynchronizingProcessor(Processor):
167
148
  if document.labels is not None:
168
149
  self.api.update_labels(
169
150
  page_id.page_id,
170
- [
171
- ConfluenceLabel(name=label, prefix="global")
172
- for label in document.labels
173
- ],
151
+ [ConfluenceLabel(name=label, prefix="global") for label in document.labels],
174
152
  )
175
153
 
154
+ if document.properties is not None:
155
+ self.api.update_content_properties_for_page(page_id.page_id, [ConfluenceContentProperty(key, value) for key, value in document.properties.items()])
156
+
176
157
  def _update_markdown(self, path: Path, *, page_id: str, space_key: str) -> None:
177
158
  """
178
159
  Writes the Confluence page ID and space key at the beginning of the Markdown file.
@@ -202,9 +183,7 @@ class SynchronizingProcessor(Processor):
202
183
  class SynchronizingProcessorFactory(ProcessorFactory):
203
184
  api: ConfluenceSession
204
185
 
205
- def __init__(
206
- self, api: ConfluenceSession, options: ConfluenceDocumentOptions
207
- ) -> None:
186
+ def __init__(self, api: ConfluenceSession, options: ConfluenceDocumentOptions) -> None:
208
187
  super().__init__(options, api.site)
209
188
  self.api = api
210
189
 
@@ -219,7 +198,5 @@ class Application(Converter):
219
198
  This is the class instantiated by the command-line application.
220
199
  """
221
200
 
222
- def __init__(
223
- self, api: ConfluenceSession, options: ConfluenceDocumentOptions
224
- ) -> None:
201
+ def __init__(self, api: ConfluenceSession, options: ConfluenceDocumentOptions) -> None:
225
202
  super().__init__(SynchronizingProcessorFactory(api, options))
md2conf/converter.py CHANGED
@@ -23,8 +23,10 @@ from urllib.parse import ParseResult, quote_plus, urlparse, urlunparse
23
23
  import lxml.etree as ET
24
24
  import markdown
25
25
  from lxml.builder import ElementMaker
26
+ from strong_typing.core import JsonType
26
27
 
27
28
  from .collection import ConfluencePageCollection
29
+ from .extra import path_relative_to
28
30
  from .mermaid import render_diagram
29
31
  from .metadata import ConfluenceSiteMetadata
30
32
  from .properties import PageError
@@ -67,6 +69,12 @@ def is_relative_url(url: str) -> bool:
67
69
  return not bool(urlparts.scheme) and not bool(urlparts.netloc)
68
70
 
69
71
 
72
+ def is_directory_within(absolute_path: Path, base_path: Path) -> bool:
73
+ "True if the absolute path is nested within the base path."
74
+
75
+ return absolute_path.as_posix().startswith(base_path.as_posix())
76
+
77
+
70
78
  def encode_title(text: str) -> str:
71
79
  "Converts a title string such that it is safe to embed into a Confluence URL."
72
80
 
@@ -145,14 +153,11 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
145
153
  load_dtd=True,
146
154
  )
147
155
 
148
- ns_attr_list = "".join(
149
- f' xmlns:{key}="{value}"' for key, value in namespaces.items()
150
- )
156
+ ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in namespaces.items())
151
157
 
152
158
  data = [
153
159
  '<?xml version="1.0"?>',
154
- f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}">'
155
- f"<root{ns_attr_list}>",
160
+ f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}"><root{ns_attr_list}>',
156
161
  ]
157
162
  data.extend(items)
158
163
  data.append("</root>")
@@ -376,6 +381,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
376
381
  page_metadata: ConfluencePageCollection,
377
382
  ) -> None:
378
383
  super().__init__()
384
+
385
+ path = path.resolve(True)
386
+ root_dir = root_dir.resolve(True)
387
+
379
388
  self.options = options
380
389
  self.path = path
381
390
  self.base_dir = path.parent
@@ -409,6 +418,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
409
418
  anchor.tail = heading.text
410
419
  heading.text = None
411
420
 
421
+ def _warn_or_raise(self, msg: str) -> None:
422
+ "Emit a warning or raise an exception when a path points to a resource that doesn't exist."
423
+
424
+ if self.options.ignore_invalid_url:
425
+ LOGGER.warning(msg)
426
+ else:
427
+ raise DocumentError(msg)
428
+
412
429
  def _transform_link(self, anchor: ET._Element) -> Optional[ET._Element]:
413
430
  url = anchor.attrib.get("href")
414
431
  if url is None or is_absolute_url(url):
@@ -417,13 +434,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
417
434
  LOGGER.debug("Found link %s relative to %s", url, self.path)
418
435
  relative_url: ParseResult = urlparse(url)
419
436
 
420
- if (
421
- not relative_url.scheme
422
- and not relative_url.netloc
423
- and not relative_url.path
424
- and not relative_url.params
425
- and not relative_url.query
426
- ):
437
+ if not relative_url.scheme and not relative_url.netloc and not relative_url.path and not relative_url.params and not relative_url.query:
427
438
  LOGGER.debug("Found local URL: %s", url)
428
439
  if self.options.heading_anchors:
429
440
  # <ac:link ac:anchor="anchor"><ac:link-body>...</ac:link-body></ac:link>
@@ -446,15 +457,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
446
457
  # convert the relative URL to absolute URL based on the base path value, then look up
447
458
  # the absolute path in the page metadata dictionary to discover the relative path
448
459
  # within Confluence that should be used
449
- absolute_path = (self.base_dir / relative_url.path).resolve(True)
450
- if not str(absolute_path).startswith(str(self.root_dir)):
451
- msg = f"relative URL {url} points to outside root path: {self.root_dir}"
452
- if self.options.ignore_invalid_url:
453
- LOGGER.warning(msg)
454
- anchor.attrib.pop("href")
455
- return None
456
- else:
457
- raise DocumentError(msg)
460
+ absolute_path = (self.base_dir / relative_url.path).resolve()
461
+ if not is_directory_within(absolute_path, self.root_dir):
462
+ anchor.attrib.pop("href")
463
+ self._warn_or_raise(f"relative URL {url} points to outside root path: {self.root_dir}")
464
+ return None
458
465
 
459
466
  link_metadata = self.page_metadata.get(absolute_path)
460
467
  if link_metadata is None:
@@ -467,9 +474,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
467
474
  raise DocumentError(msg)
468
475
 
469
476
  relative_path = os.path.relpath(absolute_path, self.base_dir)
470
- LOGGER.debug(
471
- "found link to page %s with metadata: %s", relative_path, link_metadata
472
- )
477
+ LOGGER.debug("found link to page %s with metadata: %s", relative_path, link_metadata)
473
478
  self.links.append(url)
474
479
 
475
480
  if self.options.webui_links:
@@ -478,9 +483,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
478
483
  space_key = link_metadata.space_key or self.site_metadata.space_key
479
484
 
480
485
  if space_key is None:
481
- raise DocumentError(
482
- "Confluence space key required for building full web URLs"
483
- )
486
+ raise DocumentError("Confluence space key required for building full web URLs")
484
487
 
485
488
  page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{encode_title(link_metadata.title)}"
486
489
 
@@ -522,9 +525,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
522
525
  else:
523
526
  return self._transform_attached_image(Path(src), caption, attributes)
524
527
 
525
- def _transform_external_image(
526
- self, url: str, caption: Optional[str], attributes: dict[str, Any]
527
- ) -> ET._Element:
528
+ def _transform_external_image(self, url: str, caption: Optional[str], attributes: dict[str, Any]) -> ET._Element:
528
529
  "Emits Confluence Storage Format XHTML for an external image."
529
530
 
530
531
  elements: list[ET._Element] = []
@@ -540,18 +541,28 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
540
541
 
541
542
  return AC("image", attributes, *elements)
542
543
 
543
- def _transform_attached_image(
544
- self, path: Path, caption: Optional[str], attributes: dict[str, Any]
545
- ) -> ET._Element:
544
+ def _transform_attached_image(self, path: Path, caption: Optional[str], attributes: dict[str, Any]) -> ET._Element:
546
545
  "Emits Confluence Storage Format XHTML for an attached image."
547
546
 
548
- # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
549
- png_file = path.with_suffix(".png")
550
- if path.suffix == ".svg" and (self.base_dir / png_file).exists():
551
- path = png_file
547
+ # resolve relative path into absolute path w.r.t. base dir
548
+ absolute_path = (self.base_dir / path).resolve()
549
+
550
+ if absolute_path.exists():
551
+ # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
552
+ if absolute_path.suffix == ".svg":
553
+ png_file = absolute_path.with_suffix(".png")
554
+ if png_file.exists():
555
+ absolute_path = png_file
552
556
 
553
- self.images.append(path)
554
- image_name = attachment_name(path)
557
+ if is_directory_within(absolute_path, self.root_dir):
558
+ self.images.append(absolute_path)
559
+ image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
560
+ else:
561
+ image_name = ""
562
+ self._warn_or_raise(f"path to image {path} points to outside root path {self.root_dir}")
563
+ else:
564
+ image_name = ""
565
+ self._warn_or_raise(f"path to image {path} does not exist")
555
566
 
556
567
  elements: list[ET._Element] = []
557
568
  elements.append(
@@ -607,9 +618,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
607
618
  if self.options.render_mermaid:
608
619
  image_data = render_diagram(content, self.options.diagram_output_format)
609
620
  image_hash = hashlib.md5(image_data).hexdigest()
610
- image_filename = attachment_name(
611
- f"embedded_{image_hash}.{self.options.diagram_output_format}"
612
- )
621
+ image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
613
622
  self.embedded_images[image_filename] = image_data
614
623
  return AC(
615
624
  "image",
@@ -769,9 +778,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
769
778
 
770
779
  return self._transform_alert(elem, class_name, skip)
771
780
 
772
- def _transform_alert(
773
- self, elem: ET._Element, class_name: Optional[str], skip: int
774
- ) -> ET._Element:
781
+ def _transform_alert(self, elem: ET._Element, class_name: Optional[str], skip: int) -> ET._Element:
775
782
  """
776
783
  Creates an info, tip, note or warning panel from a GitHub or GitLab alert.
777
784
 
@@ -806,14 +813,12 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
806
813
  Creates a collapsed section.
807
814
 
808
815
  Transforms
809
- [GitHub collapsed section](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections) # noqa: E501 # no way to make this link shorter
816
+ [GitHub collapsed section](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections)
810
817
  syntax into the Confluence structured macro *expand*.
811
818
  """
812
819
 
813
820
  if elem[0].tag != "summary":
814
- raise DocumentError(
815
- "expected: `<summary>` as first direct child of `<details>`"
816
- )
821
+ raise DocumentError("expected: `<summary>` as first direct child of `<details>`")
817
822
  if elem[0].tail is not None:
818
823
  raise DocumentError('expected: attribute `markdown="1"` on `<details>`')
819
824
 
@@ -905,13 +910,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
905
910
  # <blockquote>
906
911
  # <p>[!TIP] ...</p>
907
912
  # </blockquote>
908
- elif (
909
- child.tag == "blockquote"
910
- and len(child) > 0
911
- and child[0].tag == "p"
912
- and child[0].text is not None
913
- and child[0].text.startswith("[!")
914
- ):
913
+ elif child.tag == "blockquote" and len(child) > 0 and child[0].tag == "p" and child[0].text is not None and child[0].text.startswith("[!"):
915
914
  return self._transform_github_alert(child)
916
915
 
917
916
  # Alerts in GitLab
@@ -923,9 +922,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
923
922
  and len(child) > 0
924
923
  and child[0].tag == "p"
925
924
  and child[0].text is not None
926
- and starts_with_any(
927
- child[0].text, ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"]
928
- )
925
+ and starts_with_any(child[0].text, ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"])
929
926
  ):
930
927
  return self._transform_gitlab_alert(child)
931
928
 
@@ -1012,6 +1009,7 @@ class ConversionError(RuntimeError):
1012
1009
  class ConfluenceDocument:
1013
1010
  title: Optional[str]
1014
1011
  labels: Optional[list[str]]
1012
+ properties: Optional[dict[str, JsonType]]
1015
1013
  links: list[str]
1016
1014
  images: list[Path]
1017
1015
 
@@ -1041,9 +1039,7 @@ class ConfluenceDocument:
1041
1039
  else:
1042
1040
  raise PageError("missing Confluence page ID")
1043
1041
 
1044
- return page_id, ConfluenceDocument(
1045
- path, document, options, root_dir, site_metadata, page_metadata
1046
- )
1042
+ return page_id, ConfluenceDocument(path, document, options, root_dir, site_metadata, page_metadata)
1047
1043
 
1048
1044
  def __init__(
1049
1045
  self,
@@ -1102,21 +1098,42 @@ class ConfluenceDocument:
1102
1098
 
1103
1099
  self.title = document.title or converter.toc.get_title()
1104
1100
  self.labels = document.tags
1101
+ self.properties = document.properties
1105
1102
 
1106
1103
  def xhtml(self) -> str:
1107
1104
  return elements_to_string(self.root)
1108
1105
 
1109
1106
 
1110
- def attachment_name(name: Union[Path, str]) -> str:
1107
+ def attachment_name(ref: Union[Path, str]) -> str:
1111
1108
  """
1112
1109
  Safe name for use with attachment uploads.
1113
1110
 
1111
+ Mutates a relative path such that it meets Confluence's attachment naming requirements.
1112
+
1114
1113
  Allowed characters:
1114
+
1115
1115
  * Alphanumeric characters: 0-9, a-z, A-Z
1116
1116
  * Special characters: hyphen (-), underscore (_), period (.)
1117
1117
  """
1118
1118
 
1119
- return re.sub(r"[^\-0-9A-Za-z_.]", "_", str(name))
1119
+ if isinstance(ref, Path):
1120
+ path = ref
1121
+ else:
1122
+ path = Path(ref)
1123
+
1124
+ if path.drive or path.root:
1125
+ raise ValueError(f"required: relative path; got: {ref}")
1126
+
1127
+ regexp = re.compile(r"[^\-0-9A-Za-z_.]", re.UNICODE)
1128
+
1129
+ def replace_part(part: str) -> str:
1130
+ if part == "..":
1131
+ return "PAR"
1132
+ else:
1133
+ return regexp.sub("_", part)
1134
+
1135
+ parts = [replace_part(p) for p in path.parts]
1136
+ return Path(*parts).as_posix().replace("/", "_")
1120
1137
 
1121
1138
 
1122
1139
  def sanitize_confluence(html: str) -> str:
@@ -1147,14 +1164,11 @@ def _content_to_string(dtd_path: Path, content: str) -> str:
1147
1164
  load_dtd=True,
1148
1165
  )
1149
1166
 
1150
- ns_attr_list = "".join(
1151
- f' xmlns:{key}="{value}"' for key, value in namespaces.items()
1152
- )
1167
+ ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in namespaces.items())
1153
1168
 
1154
1169
  data = [
1155
1170
  '<?xml version="1.0"?>',
1156
- f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}">'
1157
- f"<root{ns_attr_list}>",
1171
+ f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}"><root{ns_attr_list}>',
1158
1172
  ]
1159
1173
  data.append(content)
1160
1174
  data.append("</root>")
md2conf/extra.py CHANGED
@@ -12,3 +12,16 @@ if sys.version_info >= (3, 12):
12
12
  from typing import override as override # noqa: F401
13
13
  else:
14
14
  from typing_extensions import override as override # noqa: F401
15
+
16
+ if sys.version_info >= (3, 12):
17
+ from pathlib import Path
18
+
19
+ def path_relative_to(destination: Path, origin: Path) -> Path:
20
+ return destination.relative_to(origin, walk_up=True)
21
+
22
+ else:
23
+ import os.path
24
+ from pathlib import Path
25
+
26
+ def path_relative_to(destination: Path, origin: Path) -> Path:
27
+ return Path(os.path.relpath(destination, start=origin))
md2conf/local.py CHANGED
@@ -45,9 +45,7 @@ class LocalProcessor(Processor):
45
45
  self.out_dir = out_dir or root_dir
46
46
 
47
47
  @override
48
- def _synchronize_tree(
49
- self, root: DocumentNode, root_id: Optional[ConfluencePageID]
50
- ) -> None:
48
+ def _synchronize_tree(self, root: DocumentNode, root_id: Optional[ConfluencePageID]) -> None:
51
49
  """
52
50
  Creates the cross-reference index.
53
51
 
@@ -59,9 +57,7 @@ class LocalProcessor(Processor):
59
57
  page_id = node.page_id
60
58
  else:
61
59
  digest = self._generate_hash(node.absolute_path)
62
- LOGGER.info(
63
- "Identifier %s assigned to page: %s", digest, node.absolute_path
64
- )
60
+ LOGGER.info("Identifier %s assigned to page: %s", digest, node.absolute_path)
65
61
  page_id = digest
66
62
 
67
63
  self.page_metadata.add(
@@ -74,9 +70,7 @@ class LocalProcessor(Processor):
74
70
  )
75
71
 
76
72
  @override
77
- def _update_page(
78
- self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
79
- ) -> None:
73
+ def _update_page(self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path) -> None:
80
74
  """
81
75
  Saves the document as Confluence Storage Format XHTML to the local disk.
82
76
  """
@@ -101,9 +95,7 @@ class LocalProcessorFactory(ProcessorFactory):
101
95
  self.out_dir = out_dir
102
96
 
103
97
  def create(self, root_dir: Path) -> Processor:
104
- return LocalProcessor(
105
- self.options, self.site, out_dir=self.out_dir, root_dir=root_dir
106
- )
98
+ return LocalProcessor(self.options, self.site, out_dir=self.out_dir, root_dir=root_dir)
107
99
 
108
100
 
109
101
  class LocalConverter(Converter):
md2conf/matcher.py CHANGED
@@ -156,6 +156,4 @@ class Matcher:
156
156
  :returns: A filtered list of entries whose name didn't match any of the exclusion rules.
157
157
  """
158
158
 
159
- return self.filter(
160
- Entry(entry.name, entry.is_dir()) for entry in os.scandir(path)
161
- )
159
+ return self.filter(Entry(entry.name, entry.is_dir()) for entry in os.scandir(path))
md2conf/mermaid.py CHANGED
@@ -19,10 +19,7 @@ LOGGER = logging.getLogger(__name__)
19
19
  def is_docker() -> bool:
20
20
  "True if the application is running in a Docker container."
21
21
 
22
- return (
23
- os.environ.get("CHROME_BIN") == "/usr/bin/chromium-browser"
24
- and os.environ.get("PUPPETEER_SKIP_DOWNLOAD") == "true"
25
- )
22
+ return os.environ.get("CHROME_BIN") == "/usr/bin/chromium-browser" and os.environ.get("PUPPETEER_SKIP_DOWNLOAD") == "true"
26
23
 
27
24
 
28
25
  def get_mmdc() -> str:
@@ -79,9 +76,7 @@ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") ->
79
76
  )
80
77
  stdout, stderr = proc.communicate(input=source.encode("utf-8"))
81
78
  if proc.returncode:
82
- messages = [
83
- f"failed to convert Mermaid diagram; exit code: {proc.returncode}"
84
- ]
79
+ messages = [f"failed to convert Mermaid diagram; exit code: {proc.returncode}"]
85
80
  console_output = stdout.decode("utf-8")
86
81
  if console_output:
87
82
  messages.append(f"output:\n{console_output}")
md2conf/processor.py CHANGED
@@ -131,15 +131,11 @@ class Processor:
131
131
  Synchronizes a single Markdown document with its corresponding Confluence page.
132
132
  """
133
133
 
134
- page_id, document = ConfluenceDocument.create(
135
- path, self.options, self.root_dir, self.site, self.page_metadata
136
- )
134
+ page_id, document = ConfluenceDocument.create(path, self.options, self.root_dir, self.site, self.page_metadata)
137
135
  self._update_page(page_id, document, path)
138
136
 
139
137
  @abstractmethod
140
- def _synchronize_tree(
141
- self, node: DocumentNode, page_id: Optional[ConfluencePageID]
142
- ) -> None:
138
+ def _synchronize_tree(self, node: DocumentNode, page_id: Optional[ConfluencePageID]) -> None:
143
139
  """
144
140
  Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
145
141
 
@@ -150,17 +146,13 @@ class Processor:
150
146
  ...
151
147
 
152
148
  @abstractmethod
153
- def _update_page(
154
- self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
155
- ) -> None:
149
+ def _update_page(self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path) -> None:
156
150
  """
157
151
  Saves the document as Confluence Storage Format XHTML.
158
152
  """
159
153
  ...
160
154
 
161
- def _index_directory(
162
- self, local_dir: Path, parent: Optional[DocumentNode]
163
- ) -> DocumentNode:
155
+ def _index_directory(self, local_dir: Path, parent: Optional[DocumentNode]) -> DocumentNode:
164
156
  """
165
157
  Indexes Markdown files in a directory hierarchy recursively.
166
158
  """
@@ -176,21 +168,21 @@ class Processor:
176
168
  continue
177
169
 
178
170
  if entry.is_file():
179
- files.append(Path(local_dir) / entry.name)
171
+ files.append(local_dir / entry.name)
180
172
  elif entry.is_dir():
181
- directories.append(Path(local_dir) / entry.name)
173
+ directories.append(local_dir / entry.name)
182
174
 
183
175
  # make page act as parent node
184
176
  parent_doc: Optional[Path] = None
185
- if (Path(local_dir) / "index.md") in files:
186
- parent_doc = Path(local_dir) / "index.md"
187
- elif (Path(local_dir) / "README.md") in files:
188
- parent_doc = Path(local_dir) / "README.md"
189
- elif (Path(local_dir) / f"{local_dir.name}.md") in files:
190
- parent_doc = Path(local_dir) / f"{local_dir.name}.md"
177
+ if (local_dir / "index.md") in files:
178
+ parent_doc = local_dir / "index.md"
179
+ elif (local_dir / "README.md") in files:
180
+ parent_doc = local_dir / "README.md"
181
+ elif (local_dir / f"{local_dir.name}.md") in files:
182
+ parent_doc = local_dir / f"{local_dir.name}.md"
191
183
 
192
184
  if parent_doc is None and self.options.keep_hierarchy:
193
- parent_doc = Path(local_dir) / "index.md"
185
+ parent_doc = local_dir / "index.md"
194
186
 
195
187
  # create a blank page for directory entry
196
188
  with open(parent_doc, "w"):
@@ -206,13 +198,7 @@ class Processor:
206
198
  parent.add_child(node)
207
199
  parent = node
208
200
  elif parent is None:
209
- # create new top-level node
210
- if self.options.root_page_id is not None:
211
- page_id = self.options.root_page_id.page_id
212
- parent = DocumentNode(local_dir, page_id=page_id)
213
- else:
214
- # local use only, raises error with remote synchronization
215
- parent = DocumentNode(local_dir, page_id=None)
201
+ raise ArgumentError(f"root page requires corresponding top-level Markdown document in {local_dir}")
216
202
 
217
203
  for file in files:
218
204
  node = self._index_file(file)
@@ -254,9 +240,7 @@ class ProcessorFactory:
254
240
  options: ConfluenceDocumentOptions
255
241
  site: ConfluenceSiteMetadata
256
242
 
257
- def __init__(
258
- self, options: ConfluenceDocumentOptions, site: ConfluenceSiteMetadata
259
- ) -> None:
243
+ def __init__(self, options: ConfluenceDocumentOptions, site: ConfluenceSiteMetadata) -> None:
260
244
  self.options = options
261
245
  self.site = site
262
246
 
@@ -283,9 +267,7 @@ class Converter:
283
267
  else:
284
268
  raise ArgumentError(f"expected: valid file or directory path; got: {path}")
285
269
 
286
- def process_directory(
287
- self, local_dir: Path, root_dir: Optional[Path] = None
288
- ) -> None:
270
+ def process_directory(self, local_dir: Path, root_dir: Optional[Path] = None) -> None:
289
271
  """
290
272
  Recursively scans a directory hierarchy for Markdown files, and processes each, resolving cross-references.
291
273
  """