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.
- {markdown_to_confluence-0.4.0.dist-info → markdown_to_confluence-0.4.1.dist-info}/METADATA +33 -11
- markdown_to_confluence-0.4.1.dist-info/RECORD +25 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +4 -12
- md2conf/api.py +189 -62
- md2conf/application.py +24 -47
- md2conf/converter.py +83 -69
- md2conf/extra.py +13 -0
- md2conf/local.py +4 -12
- md2conf/matcher.py +1 -3
- md2conf/mermaid.py +2 -7
- md2conf/processor.py +16 -34
- md2conf/properties.py +45 -12
- md2conf/scanner.py +10 -9
- markdown_to_confluence-0.4.0.dist-info/RECORD +0 -25
- {markdown_to_confluence-0.4.0.dist-info → markdown_to_confluence-0.4.1.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.4.0.dist-info → markdown_to_confluence-0.4.1.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.4.0.dist-info → markdown_to_confluence-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.4.0.dist-info → markdown_to_confluence-0.4.1.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.4.0.dist-info → markdown_to_confluence-0.4.1.dist-info}/zip-safe +0 -0
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
|
-
|
|
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
|
|
115
|
+
for image_path in document.images:
|
|
129
116
|
self.api.upload_attachment(
|
|
130
117
|
page_id.page_id,
|
|
131
|
-
attachment_name(
|
|
132
|
-
attachment_path=
|
|
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
|
-
|
|
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(
|
|
450
|
-
if not
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
#
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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(
|
|
171
|
+
files.append(local_dir / entry.name)
|
|
180
172
|
elif entry.is_dir():
|
|
181
|
-
directories.append(
|
|
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 (
|
|
186
|
-
parent_doc =
|
|
187
|
-
elif (
|
|
188
|
-
parent_doc =
|
|
189
|
-
elif (
|
|
190
|
-
parent_doc =
|
|
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 =
|
|
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
|
-
|
|
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
|
"""
|