markdown-to-confluence 0.3.5__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.3.5.dist-info → markdown_to_confluence-0.4.1.dist-info}/METADATA +150 -17
- markdown_to_confluence-0.4.1.dist-info/RECORD +25 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +20 -17
- md2conf/api.py +529 -216
- md2conf/application.py +85 -96
- md2conf/collection.py +31 -0
- md2conf/converter.py +99 -78
- md2conf/emoji.py +28 -3
- md2conf/extra.py +27 -0
- md2conf/local.py +28 -41
- md2conf/matcher.py +1 -3
- md2conf/mermaid.py +2 -7
- md2conf/metadata.py +0 -2
- md2conf/processor.py +135 -57
- md2conf/properties.py +66 -14
- md2conf/scanner.py +56 -23
- markdown_to_confluence-0.3.5.dist-info/RECORD +0 -23
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.1.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.1.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.1.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.1.dist-info}/zip-safe +0 -0
md2conf/converter.py
CHANGED
|
@@ -23,9 +23,12 @@ 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
|
|
|
28
|
+
from .collection import ConfluencePageCollection
|
|
29
|
+
from .extra import path_relative_to
|
|
27
30
|
from .mermaid import render_diagram
|
|
28
|
-
from .metadata import
|
|
31
|
+
from .metadata import ConfluenceSiteMetadata
|
|
29
32
|
from .properties import PageError
|
|
30
33
|
from .scanner import ScannedDocument, Scanner
|
|
31
34
|
|
|
@@ -66,6 +69,12 @@ def is_relative_url(url: str) -> bool:
|
|
|
66
69
|
return not bool(urlparts.scheme) and not bool(urlparts.netloc)
|
|
67
70
|
|
|
68
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
|
+
|
|
69
78
|
def encode_title(text: str) -> str:
|
|
70
79
|
"Converts a title string such that it is safe to embed into a Confluence URL."
|
|
71
80
|
|
|
@@ -91,8 +100,10 @@ def emoji_generator(
|
|
|
91
100
|
md: markdown.Markdown,
|
|
92
101
|
) -> xml.etree.ElementTree.Element:
|
|
93
102
|
name = (alias or shortname).strip(":")
|
|
94
|
-
span = xml.etree.ElementTree.Element("span", {"data-emoji": name})
|
|
103
|
+
span = xml.etree.ElementTree.Element("span", {"data-emoji-shortname": name})
|
|
95
104
|
if uc is not None:
|
|
105
|
+
span.attrib["data-emoji-unicode"] = uc
|
|
106
|
+
|
|
96
107
|
# convert series of Unicode code point hexadecimal values into characters
|
|
97
108
|
span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
|
|
98
109
|
else:
|
|
@@ -142,14 +153,11 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
|
|
|
142
153
|
load_dtd=True,
|
|
143
154
|
)
|
|
144
155
|
|
|
145
|
-
ns_attr_list = "".join(
|
|
146
|
-
f' xmlns:{key}="{value}"' for key, value in namespaces.items()
|
|
147
|
-
)
|
|
156
|
+
ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in namespaces.items())
|
|
148
157
|
|
|
149
158
|
data = [
|
|
150
159
|
'<?xml version="1.0"?>',
|
|
151
|
-
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}">'
|
|
152
|
-
f"<root{ns_attr_list}>",
|
|
160
|
+
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}"><root{ns_attr_list}>',
|
|
153
161
|
]
|
|
154
162
|
data.extend(items)
|
|
155
163
|
data.append("</root>")
|
|
@@ -362,7 +370,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
362
370
|
images: list[Path]
|
|
363
371
|
embedded_images: dict[str, bytes]
|
|
364
372
|
site_metadata: ConfluenceSiteMetadata
|
|
365
|
-
page_metadata:
|
|
373
|
+
page_metadata: ConfluencePageCollection
|
|
366
374
|
|
|
367
375
|
def __init__(
|
|
368
376
|
self,
|
|
@@ -370,9 +378,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
370
378
|
path: Path,
|
|
371
379
|
root_dir: Path,
|
|
372
380
|
site_metadata: ConfluenceSiteMetadata,
|
|
373
|
-
page_metadata:
|
|
381
|
+
page_metadata: ConfluencePageCollection,
|
|
374
382
|
) -> None:
|
|
375
383
|
super().__init__()
|
|
384
|
+
|
|
385
|
+
path = path.resolve(True)
|
|
386
|
+
root_dir = root_dir.resolve(True)
|
|
387
|
+
|
|
376
388
|
self.options = options
|
|
377
389
|
self.path = path
|
|
378
390
|
self.base_dir = path.parent
|
|
@@ -406,6 +418,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
406
418
|
anchor.tail = heading.text
|
|
407
419
|
heading.text = None
|
|
408
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
|
+
|
|
409
429
|
def _transform_link(self, anchor: ET._Element) -> Optional[ET._Element]:
|
|
410
430
|
url = anchor.attrib.get("href")
|
|
411
431
|
if url is None or is_absolute_url(url):
|
|
@@ -414,13 +434,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
414
434
|
LOGGER.debug("Found link %s relative to %s", url, self.path)
|
|
415
435
|
relative_url: ParseResult = urlparse(url)
|
|
416
436
|
|
|
417
|
-
if
|
|
418
|
-
not relative_url.scheme
|
|
419
|
-
and not relative_url.netloc
|
|
420
|
-
and not relative_url.path
|
|
421
|
-
and not relative_url.params
|
|
422
|
-
and not relative_url.query
|
|
423
|
-
):
|
|
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:
|
|
424
438
|
LOGGER.debug("Found local URL: %s", url)
|
|
425
439
|
if self.options.heading_anchors:
|
|
426
440
|
# <ac:link ac:anchor="anchor"><ac:link-body>...</ac:link-body></ac:link>
|
|
@@ -443,15 +457,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
443
457
|
# convert the relative URL to absolute URL based on the base path value, then look up
|
|
444
458
|
# the absolute path in the page metadata dictionary to discover the relative path
|
|
445
459
|
# within Confluence that should be used
|
|
446
|
-
absolute_path = (self.base_dir / relative_url.path).resolve(
|
|
447
|
-
if not
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
anchor.attrib.pop("href")
|
|
452
|
-
return None
|
|
453
|
-
else:
|
|
454
|
-
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
|
|
455
465
|
|
|
456
466
|
link_metadata = self.page_metadata.get(absolute_path)
|
|
457
467
|
if link_metadata is None:
|
|
@@ -464,9 +474,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
464
474
|
raise DocumentError(msg)
|
|
465
475
|
|
|
466
476
|
relative_path = os.path.relpath(absolute_path, self.base_dir)
|
|
467
|
-
LOGGER.debug(
|
|
468
|
-
"found link to page %s with metadata: %s", relative_path, link_metadata
|
|
469
|
-
)
|
|
477
|
+
LOGGER.debug("found link to page %s with metadata: %s", relative_path, link_metadata)
|
|
470
478
|
self.links.append(url)
|
|
471
479
|
|
|
472
480
|
if self.options.webui_links:
|
|
@@ -475,9 +483,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
475
483
|
space_key = link_metadata.space_key or self.site_metadata.space_key
|
|
476
484
|
|
|
477
485
|
if space_key is None:
|
|
478
|
-
raise DocumentError(
|
|
479
|
-
"Confluence space key required for building full web URLs"
|
|
480
|
-
)
|
|
486
|
+
raise DocumentError("Confluence space key required for building full web URLs")
|
|
481
487
|
|
|
482
488
|
page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{encode_title(link_metadata.title)}"
|
|
483
489
|
|
|
@@ -519,9 +525,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
519
525
|
else:
|
|
520
526
|
return self._transform_attached_image(Path(src), caption, attributes)
|
|
521
527
|
|
|
522
|
-
def _transform_external_image(
|
|
523
|
-
self, url: str, caption: Optional[str], attributes: dict[str, Any]
|
|
524
|
-
) -> ET._Element:
|
|
528
|
+
def _transform_external_image(self, url: str, caption: Optional[str], attributes: dict[str, Any]) -> ET._Element:
|
|
525
529
|
"Emits Confluence Storage Format XHTML for an external image."
|
|
526
530
|
|
|
527
531
|
elements: list[ET._Element] = []
|
|
@@ -537,18 +541,28 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
537
541
|
|
|
538
542
|
return AC("image", attributes, *elements)
|
|
539
543
|
|
|
540
|
-
def _transform_attached_image(
|
|
541
|
-
self, path: Path, caption: Optional[str], attributes: dict[str, Any]
|
|
542
|
-
) -> ET._Element:
|
|
544
|
+
def _transform_attached_image(self, path: Path, caption: Optional[str], attributes: dict[str, Any]) -> ET._Element:
|
|
543
545
|
"Emits Confluence Storage Format XHTML for an attached image."
|
|
544
546
|
|
|
545
|
-
#
|
|
546
|
-
|
|
547
|
-
if path.suffix == ".svg" and (self.base_dir / png_file).exists():
|
|
548
|
-
path = png_file
|
|
547
|
+
# resolve relative path into absolute path w.r.t. base dir
|
|
548
|
+
absolute_path = (self.base_dir / path).resolve()
|
|
549
549
|
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
556
|
+
|
|
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")
|
|
552
566
|
|
|
553
567
|
elements: list[ET._Element] = []
|
|
554
568
|
elements.append(
|
|
@@ -604,9 +618,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
604
618
|
if self.options.render_mermaid:
|
|
605
619
|
image_data = render_diagram(content, self.options.diagram_output_format)
|
|
606
620
|
image_hash = hashlib.md5(image_data).hexdigest()
|
|
607
|
-
image_filename = attachment_name(
|
|
608
|
-
f"embedded_{image_hash}.{self.options.diagram_output_format}"
|
|
609
|
-
)
|
|
621
|
+
image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
|
|
610
622
|
self.embedded_images[image_filename] = image_data
|
|
611
623
|
return AC(
|
|
612
624
|
"image",
|
|
@@ -766,9 +778,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
766
778
|
|
|
767
779
|
return self._transform_alert(elem, class_name, skip)
|
|
768
780
|
|
|
769
|
-
def _transform_alert(
|
|
770
|
-
self, elem: ET._Element, class_name: Optional[str], skip: int
|
|
771
|
-
) -> ET._Element:
|
|
781
|
+
def _transform_alert(self, elem: ET._Element, class_name: Optional[str], skip: int) -> ET._Element:
|
|
772
782
|
"""
|
|
773
783
|
Creates an info, tip, note or warning panel from a GitHub or GitLab alert.
|
|
774
784
|
|
|
@@ -803,14 +813,12 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
803
813
|
Creates a collapsed section.
|
|
804
814
|
|
|
805
815
|
Transforms
|
|
806
|
-
[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)
|
|
807
817
|
syntax into the Confluence structured macro *expand*.
|
|
808
818
|
"""
|
|
809
819
|
|
|
810
820
|
if elem[0].tag != "summary":
|
|
811
|
-
raise DocumentError(
|
|
812
|
-
"expected: `<summary>` as first direct child of `<details>`"
|
|
813
|
-
)
|
|
821
|
+
raise DocumentError("expected: `<summary>` as first direct child of `<details>`")
|
|
814
822
|
if elem[0].tail is not None:
|
|
815
823
|
raise DocumentError('expected: attribute `markdown="1"` on `<details>`')
|
|
816
824
|
|
|
@@ -834,7 +842,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
834
842
|
)
|
|
835
843
|
|
|
836
844
|
def _transform_emoji(self, elem: ET._Element) -> ET._Element:
|
|
837
|
-
shortname = elem.attrib.get("data-emoji", "")
|
|
845
|
+
shortname = elem.attrib.get("data-emoji-shortname", "")
|
|
846
|
+
unicode = elem.attrib.get("data-emoji-unicode", None)
|
|
838
847
|
alt = elem.text or ""
|
|
839
848
|
|
|
840
849
|
# <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="😉"/>
|
|
@@ -844,8 +853,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
844
853
|
"emoticon",
|
|
845
854
|
{
|
|
846
855
|
# use "blue-star" as a placeholder name to ensure wiki page loads in timely manner
|
|
847
|
-
ET.QName(namespaces["ac"], "name"):
|
|
856
|
+
ET.QName(namespaces["ac"], "name"): shortname,
|
|
848
857
|
ET.QName(namespaces["ac"], "emoji-shortname"): f":{shortname}:",
|
|
858
|
+
ET.QName(namespaces["ac"], "emoji-id"): unicode,
|
|
849
859
|
ET.QName(namespaces["ac"], "emoji-fallback"): alt,
|
|
850
860
|
},
|
|
851
861
|
)
|
|
@@ -900,13 +910,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
900
910
|
# <blockquote>
|
|
901
911
|
# <p>[!TIP] ...</p>
|
|
902
912
|
# </blockquote>
|
|
903
|
-
elif (
|
|
904
|
-
child.tag == "blockquote"
|
|
905
|
-
and len(child) > 0
|
|
906
|
-
and child[0].tag == "p"
|
|
907
|
-
and child[0].text is not None
|
|
908
|
-
and child[0].text.startswith("[!")
|
|
909
|
-
):
|
|
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("[!"):
|
|
910
914
|
return self._transform_github_alert(child)
|
|
911
915
|
|
|
912
916
|
# Alerts in GitLab
|
|
@@ -918,9 +922,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
918
922
|
and len(child) > 0
|
|
919
923
|
and child[0].tag == "p"
|
|
920
924
|
and child[0].text is not None
|
|
921
|
-
and starts_with_any(
|
|
922
|
-
child[0].text, ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"]
|
|
923
|
-
)
|
|
925
|
+
and starts_with_any(child[0].text, ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"])
|
|
924
926
|
):
|
|
925
927
|
return self._transform_gitlab_alert(child)
|
|
926
928
|
|
|
@@ -943,7 +945,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
943
945
|
elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
|
|
944
946
|
return self._transform_block(child[0])
|
|
945
947
|
|
|
946
|
-
elif child.tag == "span" and child.attrib.has_key("data-emoji"):
|
|
948
|
+
elif child.tag == "span" and child.attrib.has_key("data-emoji-shortname"):
|
|
947
949
|
return self._transform_emoji(child)
|
|
948
950
|
|
|
949
951
|
return None
|
|
@@ -1006,6 +1008,8 @@ class ConversionError(RuntimeError):
|
|
|
1006
1008
|
|
|
1007
1009
|
class ConfluenceDocument:
|
|
1008
1010
|
title: Optional[str]
|
|
1011
|
+
labels: Optional[list[str]]
|
|
1012
|
+
properties: Optional[dict[str, JsonType]]
|
|
1009
1013
|
links: list[str]
|
|
1010
1014
|
images: list[Path]
|
|
1011
1015
|
|
|
@@ -1019,7 +1023,7 @@ class ConfluenceDocument:
|
|
|
1019
1023
|
options: ConfluenceDocumentOptions,
|
|
1020
1024
|
root_dir: Path,
|
|
1021
1025
|
site_metadata: ConfluenceSiteMetadata,
|
|
1022
|
-
page_metadata:
|
|
1026
|
+
page_metadata: ConfluencePageCollection,
|
|
1023
1027
|
) -> tuple[ConfluencePageID, "ConfluenceDocument"]:
|
|
1024
1028
|
path = path.resolve(True)
|
|
1025
1029
|
|
|
@@ -1035,9 +1039,7 @@ class ConfluenceDocument:
|
|
|
1035
1039
|
else:
|
|
1036
1040
|
raise PageError("missing Confluence page ID")
|
|
1037
1041
|
|
|
1038
|
-
return page_id, ConfluenceDocument(
|
|
1039
|
-
path, document, options, root_dir, site_metadata, page_metadata
|
|
1040
|
-
)
|
|
1042
|
+
return page_id, ConfluenceDocument(path, document, options, root_dir, site_metadata, page_metadata)
|
|
1041
1043
|
|
|
1042
1044
|
def __init__(
|
|
1043
1045
|
self,
|
|
@@ -1046,7 +1048,7 @@ class ConfluenceDocument:
|
|
|
1046
1048
|
options: ConfluenceDocumentOptions,
|
|
1047
1049
|
root_dir: Path,
|
|
1048
1050
|
site_metadata: ConfluenceSiteMetadata,
|
|
1049
|
-
page_metadata:
|
|
1051
|
+
page_metadata: ConfluencePageCollection,
|
|
1050
1052
|
) -> None:
|
|
1051
1053
|
self.options = options
|
|
1052
1054
|
|
|
@@ -1095,21 +1097,43 @@ class ConfluenceDocument:
|
|
|
1095
1097
|
self.embedded_images = converter.embedded_images
|
|
1096
1098
|
|
|
1097
1099
|
self.title = document.title or converter.toc.get_title()
|
|
1100
|
+
self.labels = document.tags
|
|
1101
|
+
self.properties = document.properties
|
|
1098
1102
|
|
|
1099
1103
|
def xhtml(self) -> str:
|
|
1100
1104
|
return elements_to_string(self.root)
|
|
1101
1105
|
|
|
1102
1106
|
|
|
1103
|
-
def attachment_name(
|
|
1107
|
+
def attachment_name(ref: Union[Path, str]) -> str:
|
|
1104
1108
|
"""
|
|
1105
1109
|
Safe name for use with attachment uploads.
|
|
1106
1110
|
|
|
1111
|
+
Mutates a relative path such that it meets Confluence's attachment naming requirements.
|
|
1112
|
+
|
|
1107
1113
|
Allowed characters:
|
|
1114
|
+
|
|
1108
1115
|
* Alphanumeric characters: 0-9, a-z, A-Z
|
|
1109
1116
|
* Special characters: hyphen (-), underscore (_), period (.)
|
|
1110
1117
|
"""
|
|
1111
1118
|
|
|
1112
|
-
|
|
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("/", "_")
|
|
1113
1137
|
|
|
1114
1138
|
|
|
1115
1139
|
def sanitize_confluence(html: str) -> str:
|
|
@@ -1140,14 +1164,11 @@ def _content_to_string(dtd_path: Path, content: str) -> str:
|
|
|
1140
1164
|
load_dtd=True,
|
|
1141
1165
|
)
|
|
1142
1166
|
|
|
1143
|
-
ns_attr_list = "".join(
|
|
1144
|
-
f' xmlns:{key}="{value}"' for key, value in namespaces.items()
|
|
1145
|
-
)
|
|
1167
|
+
ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in namespaces.items())
|
|
1146
1168
|
|
|
1147
1169
|
data = [
|
|
1148
1170
|
'<?xml version="1.0"?>',
|
|
1149
|
-
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path}">'
|
|
1150
|
-
f"<root{ns_attr_list}>",
|
|
1171
|
+
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}"><root{ns_attr_list}>',
|
|
1151
1172
|
]
|
|
1152
1173
|
data.append(content)
|
|
1153
1174
|
data.append("</root>")
|
md2conf/emoji.py
CHANGED
|
@@ -10,7 +10,24 @@ import pathlib
|
|
|
10
10
|
|
|
11
11
|
import pymdownx.emoji1_db as emoji_db
|
|
12
12
|
|
|
13
|
-
EMOJI_PAGE_ID = "
|
|
13
|
+
EMOJI_PAGE_ID = "13500452"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_html(cp: int) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Returns the safe HTML representation for a Unicode code point.
|
|
19
|
+
|
|
20
|
+
Converts non-ASCII and non-printable characters into HTML entities with decimal notation.
|
|
21
|
+
|
|
22
|
+
:param cp: Unicode code point.
|
|
23
|
+
:returns: An HTML representation of the Unicode character.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
ch = chr(cp)
|
|
27
|
+
if ch.isascii() and ch.isalnum():
|
|
28
|
+
return ch
|
|
29
|
+
else:
|
|
30
|
+
return f"&#{cp};"
|
|
14
31
|
|
|
15
32
|
|
|
16
33
|
def generate_source(path: pathlib.Path) -> None:
|
|
@@ -47,11 +64,19 @@ def generate_target(path: pathlib.Path) -> None:
|
|
|
47
64
|
print("<thead><tr><th>Icon</th><th>Emoji code</th></tr></thead>", file=f)
|
|
48
65
|
print("<tbody>", file=f)
|
|
49
66
|
for key, data in emojis.items():
|
|
67
|
+
unicode = data["unicode"]
|
|
50
68
|
key = key.strip(":")
|
|
51
|
-
|
|
69
|
+
html = "".join(to_html(int(item, base=16)) for item in unicode.split("-"))
|
|
52
70
|
|
|
53
71
|
print(
|
|
54
|
-
f
|
|
72
|
+
f"<tr>\n"
|
|
73
|
+
f" <td>\n"
|
|
74
|
+
f' <ac:emoticon ac:name="{key}" ac:emoji-shortname=":{key}:" ac:emoji-id="{unicode}" ac:emoji-fallback="{html}"/>\n'
|
|
75
|
+
f" </td>\n"
|
|
76
|
+
f" <td>\n"
|
|
77
|
+
f" <code>:{key}:</code>\n"
|
|
78
|
+
f" </td>\n"
|
|
79
|
+
f"</tr>",
|
|
55
80
|
file=f,
|
|
56
81
|
)
|
|
57
82
|
print("</tbody>", file=f)
|
md2conf/extra.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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 sys
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 12):
|
|
12
|
+
from typing import override as override # noqa: F401
|
|
13
|
+
else:
|
|
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
|
@@ -6,17 +6,15 @@ 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
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
from typing import Optional
|
|
14
13
|
|
|
15
14
|
from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
|
|
15
|
+
from .extra import override
|
|
16
16
|
from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
|
|
17
|
-
from .processor import Converter, Processor, ProcessorFactory
|
|
18
|
-
from .properties import PageError
|
|
19
|
-
from .scanner import Scanner
|
|
17
|
+
from .processor import Converter, DocumentNode, Processor, ProcessorFactory
|
|
20
18
|
|
|
21
19
|
LOGGER = logging.getLogger(__name__)
|
|
22
20
|
|
|
@@ -46,44 +44,35 @@ class LocalProcessor(Processor):
|
|
|
46
44
|
super().__init__(options, site, root_dir)
|
|
47
45
|
self.out_dir = out_dir or root_dir
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
) -> ConfluencePageMetadata:
|
|
52
|
-
"""
|
|
53
|
-
Extracts metadata from a Markdown file.
|
|
47
|
+
@override
|
|
48
|
+
def _synchronize_tree(self, root: DocumentNode, root_id: Optional[ConfluencePageID]) -> None:
|
|
54
49
|
"""
|
|
50
|
+
Creates the cross-reference index.
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
document = Scanner().read(absolute_path)
|
|
58
|
-
if document.page_id is not None:
|
|
59
|
-
page_id = document.page_id
|
|
60
|
-
space_key = document.space_key or self.site.space_key or "HOME"
|
|
61
|
-
else:
|
|
62
|
-
if parent_id is None:
|
|
63
|
-
raise PageError(
|
|
64
|
-
f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
hash = hashlib.md5(document.text.encode("utf-8"))
|
|
68
|
-
digest = "".join(f"{c:x}" for c in hash.digest())
|
|
69
|
-
LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
|
|
70
|
-
page_id = digest
|
|
71
|
-
space_key = self.site.space_key or "HOME"
|
|
72
|
-
|
|
73
|
-
return ConfluencePageMetadata(
|
|
74
|
-
page_id=page_id,
|
|
75
|
-
space_key=space_key,
|
|
76
|
-
title="",
|
|
77
|
-
overwrite=True,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
def _save_document(
|
|
81
|
-
self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
|
|
82
|
-
) -> None:
|
|
52
|
+
Does not change Markdown files.
|
|
83
53
|
"""
|
|
84
|
-
Saves a new version of a Confluence document.
|
|
85
54
|
|
|
86
|
-
|
|
55
|
+
for node in root.all():
|
|
56
|
+
if node.page_id is not None:
|
|
57
|
+
page_id = node.page_id
|
|
58
|
+
else:
|
|
59
|
+
digest = self._generate_hash(node.absolute_path)
|
|
60
|
+
LOGGER.info("Identifier %s assigned to page: %s", digest, node.absolute_path)
|
|
61
|
+
page_id = digest
|
|
62
|
+
|
|
63
|
+
self.page_metadata.add(
|
|
64
|
+
node.absolute_path,
|
|
65
|
+
ConfluencePageMetadata(
|
|
66
|
+
page_id=page_id,
|
|
67
|
+
space_key=node.space_key or self.site.space_key or "HOME",
|
|
68
|
+
title=node.title or "",
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def _update_page(self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Saves the document as Confluence Storage Format XHTML to the local disk.
|
|
87
76
|
"""
|
|
88
77
|
|
|
89
78
|
content = document.xhtml()
|
|
@@ -106,9 +95,7 @@ class LocalProcessorFactory(ProcessorFactory):
|
|
|
106
95
|
self.out_dir = out_dir
|
|
107
96
|
|
|
108
97
|
def create(self, root_dir: Path) -> Processor:
|
|
109
|
-
return LocalProcessor(
|
|
110
|
-
self.options, self.site, out_dir=self.out_dir, root_dir=root_dir
|
|
111
|
-
)
|
|
98
|
+
return LocalProcessor(self.options, self.site, out_dir=self.out_dir, root_dir=root_dir)
|
|
112
99
|
|
|
113
100
|
|
|
114
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/metadata.py
CHANGED
|
@@ -33,10 +33,8 @@ class ConfluencePageMetadata:
|
|
|
33
33
|
:param page_id: Confluence page ID.
|
|
34
34
|
:param space_key: Confluence space key.
|
|
35
35
|
:param title: Document title.
|
|
36
|
-
:param overwrite: True if operations are allowed to update document properties (e.g. title).
|
|
37
36
|
"""
|
|
38
37
|
|
|
39
38
|
page_id: str
|
|
40
39
|
space_key: str
|
|
41
40
|
title: str
|
|
42
|
-
overwrite: bool
|