markdown-to-confluence 0.4.2__py3-none-any.whl → 0.4.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.
- {markdown_to_confluence-0.4.2.dist-info → markdown_to_confluence-0.4.4.dist-info}/METADATA +61 -14
- markdown_to_confluence-0.4.4.dist-info/RECORD +31 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +42 -10
- md2conf/api.py +3 -1
- md2conf/application.py +6 -3
- md2conf/converter.py +440 -565
- md2conf/csf.py +151 -0
- md2conf/domain.py +46 -0
- md2conf/drawio.py +49 -0
- md2conf/local.py +9 -4
- md2conf/markdown.py +114 -0
- md2conf/processor.py +2 -1
- md2conf/toc.py +89 -0
- md2conf/uri.py +46 -0
- md2conf/xml.py +47 -14
- markdown_to_confluence-0.4.2.dist-info/RECORD +0 -27
- md2conf/emoji.py +0 -83
- {markdown_to_confluence-0.4.2.dist-info → markdown_to_confluence-0.4.4.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.4.2.dist-info → markdown_to_confluence-0.4.4.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.4.2.dist-info → markdown_to_confluence-0.4.4.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.4.2.dist-info → markdown_to_confluence-0.4.4.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.4.2.dist-info → markdown_to_confluence-0.4.4.dist-info}/zip-safe +0 -0
md2conf/csf.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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 importlib.resources as resources
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, TypeVar
|
|
13
|
+
|
|
14
|
+
import lxml.etree as ET
|
|
15
|
+
from lxml.builder import ElementMaker
|
|
16
|
+
|
|
17
|
+
# XML namespaces typically associated with Confluence Storage Format documents
|
|
18
|
+
_namespaces = {
|
|
19
|
+
"ac": "http://atlassian.com/content",
|
|
20
|
+
"ri": "http://atlassian.com/resource/identifier",
|
|
21
|
+
}
|
|
22
|
+
for key, value in _namespaces.items():
|
|
23
|
+
ET.register_namespace(key, value)
|
|
24
|
+
|
|
25
|
+
HTML = ElementMaker()
|
|
26
|
+
AC_ELEM = ElementMaker(namespace=_namespaces["ac"])
|
|
27
|
+
RI_ELEM = ElementMaker(namespace=_namespaces["ri"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ParseError(RuntimeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _qname(namespace_uri: str, name: str) -> str:
|
|
35
|
+
return ET.QName(namespace_uri, name).text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def AC_ATTR(name: str) -> str:
|
|
39
|
+
return _qname(_namespaces["ac"], name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def RI_ATTR(name: str) -> str:
|
|
43
|
+
return _qname(_namespaces["ri"], name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
R = TypeVar("R")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def with_entities(func: Callable[[Path], R]) -> R:
|
|
50
|
+
"Invokes a callable in the context of an entity definition file."
|
|
51
|
+
|
|
52
|
+
resource_path = resources.files(__package__).joinpath("entities.dtd")
|
|
53
|
+
with resources.as_file(resource_path) as dtd_path:
|
|
54
|
+
return func(dtd_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
|
|
58
|
+
"""
|
|
59
|
+
Creates an XML document tree from XML fragment strings.
|
|
60
|
+
|
|
61
|
+
This function
|
|
62
|
+
* adds an XML declaration,
|
|
63
|
+
* wraps the content in a root element,
|
|
64
|
+
* adds namespace declarations associated with Confluence documents.
|
|
65
|
+
|
|
66
|
+
:param dtd_path: Path to a DTD document that defines entities like `¢` or `©`.
|
|
67
|
+
:param items: Strings to parse into XML fragments.
|
|
68
|
+
:returns: An XML document as an element tree.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
parser = ET.XMLParser(
|
|
72
|
+
remove_blank_text=True,
|
|
73
|
+
remove_comments=True,
|
|
74
|
+
strip_cdata=False,
|
|
75
|
+
load_dtd=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in _namespaces.items())
|
|
79
|
+
|
|
80
|
+
data = [
|
|
81
|
+
'<?xml version="1.0"?>',
|
|
82
|
+
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}"><root{ns_attr_list}>',
|
|
83
|
+
]
|
|
84
|
+
data.extend(items)
|
|
85
|
+
data.append("</root>")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
return ET.fromstringlist(data, parser=parser)
|
|
89
|
+
except ET.XMLSyntaxError as ex:
|
|
90
|
+
raise ParseError() from ex
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def elements_from_strings(items: list[str]) -> ET._Element:
|
|
94
|
+
"""
|
|
95
|
+
Creates a Confluence Storage Format XML document tree from XML fragment strings.
|
|
96
|
+
|
|
97
|
+
A root element is created to hold several XML fragments.
|
|
98
|
+
|
|
99
|
+
:param items: Strings to parse into XML fragments.
|
|
100
|
+
:returns: An XML document as an element tree.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
return with_entities(lambda dtd_path: _elements_from_strings(dtd_path, items))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def elements_from_string(content: str) -> ET._Element:
|
|
107
|
+
"""
|
|
108
|
+
Creates a Confluence Storage Format XML document tree from an XML string.
|
|
109
|
+
|
|
110
|
+
:param content: String to parse into XML.
|
|
111
|
+
:returns: An XML document as an element tree.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
return elements_from_strings([content])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _content_to_string(dtd_path: Path, content: str) -> str:
|
|
118
|
+
tree = _elements_from_strings(dtd_path, [content])
|
|
119
|
+
return ET.tostring(tree, pretty_print=True).decode("utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def content_to_string(content: str) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Converts a Confluence Storage Format document returned by the Confluence REST API into a readable XML document.
|
|
125
|
+
|
|
126
|
+
This function
|
|
127
|
+
* adds an XML declaration,
|
|
128
|
+
* wraps the content in a root element,
|
|
129
|
+
* adds namespace declarations associated with Confluence documents.
|
|
130
|
+
|
|
131
|
+
:param content: Confluence Storage Format content as a string.
|
|
132
|
+
:returns: XML as a string.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
return with_entities(lambda dtd_path: _content_to_string(dtd_path, content))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def elements_to_string(root: ET._Element) -> str:
|
|
139
|
+
"""
|
|
140
|
+
Converts a Confluence Storage Format element tree into an XML string to push to Confluence REST API.
|
|
141
|
+
|
|
142
|
+
:param root: Synthesized XML element tree of a Confluence Storage Format document.
|
|
143
|
+
:returns: XML as a string.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
xml = ET.tostring(root, encoding="utf8", method="xml").decode("utf8")
|
|
147
|
+
m = re.match(r"^<root\s+[^>]*>(.*)</root>\s*$", xml, re.DOTALL)
|
|
148
|
+
if m:
|
|
149
|
+
return m.group(1)
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError("expected: Confluence content")
|
md2conf/domain.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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 Literal, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ConfluencePageID:
|
|
15
|
+
page_id: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ConfluenceDocumentOptions:
|
|
20
|
+
"""
|
|
21
|
+
Options that control the generated page content.
|
|
22
|
+
|
|
23
|
+
:param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
|
|
24
|
+
plain text; when false, raise an exception.
|
|
25
|
+
:param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
|
|
26
|
+
conversion rules for the identifier.
|
|
27
|
+
:param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
|
|
28
|
+
:param root_page_id: Confluence page to assume root page role for publishing a directory of Markdown files.
|
|
29
|
+
:param keep_hierarchy: Whether to maintain source directory structure when exporting to Confluence.
|
|
30
|
+
:param prefer_raster: Whether to choose PNG files over SVG files when available.
|
|
31
|
+
:param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
|
|
32
|
+
:param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
|
|
33
|
+
:param diagram_output_format: Target image format for diagrams.
|
|
34
|
+
:param webui_links: When true, convert relative URLs to Confluence Web UI links.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
ignore_invalid_url: bool = False
|
|
38
|
+
heading_anchors: bool = False
|
|
39
|
+
generated_by: Optional[str] = "This page has been generated with a tool."
|
|
40
|
+
root_page_id: Optional[ConfluencePageID] = None
|
|
41
|
+
keep_hierarchy: bool = False
|
|
42
|
+
prefer_raster: bool = True
|
|
43
|
+
render_drawio: bool = False
|
|
44
|
+
render_mermaid: bool = False
|
|
45
|
+
diagram_output_format: Literal["png", "svg"] = "png"
|
|
46
|
+
webui_links: bool = False
|
md2conf/drawio.py
CHANGED
|
@@ -7,6 +7,11 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import base64
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import os.path
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
10
15
|
import typing
|
|
11
16
|
import zlib
|
|
12
17
|
from pathlib import Path
|
|
@@ -15,6 +20,8 @@ from urllib.parse import unquote_to_bytes
|
|
|
15
20
|
|
|
16
21
|
import lxml.etree as ET
|
|
17
22
|
|
|
23
|
+
LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
18
25
|
|
|
19
26
|
class DrawioError(ValueError):
|
|
20
27
|
"""
|
|
@@ -220,3 +227,45 @@ def extract_diagram(path: Path) -> bytes:
|
|
|
220
227
|
raise DrawioError(f"unrecognized file type for {path.name}")
|
|
221
228
|
|
|
222
229
|
return ET.tostring(root, encoding="utf8", method="xml")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render_diagram(source: Path, output_format: typing.Literal["png", "svg"] = "png") -> bytes:
|
|
233
|
+
"Generates a PNG or SVG image from a draw.io diagram source."
|
|
234
|
+
|
|
235
|
+
executable = shutil.which("draw.io")
|
|
236
|
+
if executable is None:
|
|
237
|
+
raise DrawioError("draw.io executable not found")
|
|
238
|
+
|
|
239
|
+
target = f"tmp_drawio.{output_format}"
|
|
240
|
+
|
|
241
|
+
cmd = [executable, "--export", "--format", output_format, "--output", target]
|
|
242
|
+
if output_format == "png":
|
|
243
|
+
cmd.extend(["--scale", "2", "--transparent"])
|
|
244
|
+
elif output_format == "svg":
|
|
245
|
+
cmd.append("--embed-svg-images")
|
|
246
|
+
cmd.append(str(source))
|
|
247
|
+
|
|
248
|
+
LOGGER.debug("Executing: %s", " ".join(cmd))
|
|
249
|
+
try:
|
|
250
|
+
proc = subprocess.Popen(
|
|
251
|
+
cmd,
|
|
252
|
+
stdout=subprocess.PIPE,
|
|
253
|
+
stderr=subprocess.PIPE,
|
|
254
|
+
text=False,
|
|
255
|
+
)
|
|
256
|
+
stdout, stderr = proc.communicate()
|
|
257
|
+
if proc.returncode:
|
|
258
|
+
messages = [f"failed to convert draw.io diagram; exit code: {proc.returncode}"]
|
|
259
|
+
console_output = stdout.decode("utf-8")
|
|
260
|
+
if console_output:
|
|
261
|
+
messages.append(f"output:\n{console_output}")
|
|
262
|
+
console_error = stderr.decode("utf-8")
|
|
263
|
+
if console_error:
|
|
264
|
+
messages.append(f"error:\n{console_error}")
|
|
265
|
+
raise DrawioError("\n".join(messages))
|
|
266
|
+
with open(target, "rb") as f:
|
|
267
|
+
return f.read()
|
|
268
|
+
|
|
269
|
+
finally:
|
|
270
|
+
if os.path.exists(target):
|
|
271
|
+
os.remove(target)
|
md2conf/local.py
CHANGED
|
@@ -11,7 +11,8 @@ import os
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional
|
|
13
13
|
|
|
14
|
-
from .converter import ConfluenceDocument
|
|
14
|
+
from .converter import ConfluenceDocument
|
|
15
|
+
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
15
16
|
from .extra import override
|
|
16
17
|
from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
|
|
17
18
|
from .processor import Converter, DocumentNode, Processor, ProcessorFactory
|
|
@@ -77,10 +78,14 @@ class LocalProcessor(Processor):
|
|
|
77
78
|
"""
|
|
78
79
|
|
|
79
80
|
content = document.xhtml()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
csf_path = self.out_dir / path.relative_to(self.root_dir).with_suffix(".csf")
|
|
82
|
+
csf_dir = csf_path.parent
|
|
83
|
+
os.makedirs(csf_dir, exist_ok=True)
|
|
84
|
+
with open(csf_path, "w", encoding="utf-8") as f:
|
|
83
85
|
f.write(content)
|
|
86
|
+
for name, data in document.embedded_files.items():
|
|
87
|
+
with open(csf_dir / name, "wb") as f:
|
|
88
|
+
f.write(data)
|
|
84
89
|
|
|
85
90
|
|
|
86
91
|
class LocalProcessorFactory(ProcessorFactory):
|
md2conf/markdown.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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 xml.etree.ElementTree
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import markdown
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _emoji_generator(
|
|
16
|
+
index: str,
|
|
17
|
+
shortname: str,
|
|
18
|
+
alias: Optional[str],
|
|
19
|
+
uc: Optional[str],
|
|
20
|
+
alt: str,
|
|
21
|
+
title: Optional[str],
|
|
22
|
+
category: Optional[str],
|
|
23
|
+
options: dict[str, Any],
|
|
24
|
+
md: markdown.Markdown,
|
|
25
|
+
) -> xml.etree.ElementTree.Element:
|
|
26
|
+
"""
|
|
27
|
+
Custom generator for `pymdownx.emoji`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name = (alias or shortname).strip(":")
|
|
31
|
+
emoji = xml.etree.ElementTree.Element("x-emoji", {"data-shortname": name})
|
|
32
|
+
if uc is not None:
|
|
33
|
+
emoji.attrib["data-unicode"] = uc
|
|
34
|
+
|
|
35
|
+
# convert series of Unicode code point hexadecimal values into characters
|
|
36
|
+
emoji.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
|
|
37
|
+
else:
|
|
38
|
+
emoji.text = alt
|
|
39
|
+
|
|
40
|
+
return emoji
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _verbatim_formatter(
|
|
44
|
+
source: str,
|
|
45
|
+
language: str,
|
|
46
|
+
css_class: str,
|
|
47
|
+
options: dict[str, Any],
|
|
48
|
+
md: markdown.Markdown,
|
|
49
|
+
classes: Optional[list[str]] = None,
|
|
50
|
+
id_value: str = "",
|
|
51
|
+
attrs: Optional[dict[str, str]] = None,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Custom formatter for `pymdownx.superfences`.
|
|
56
|
+
|
|
57
|
+
Used by language `math` (a.k.a. `pymdownx.arithmatex`) and pseudo-language `csf` (Confluence Storage Format pass-through).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
if classes is None:
|
|
61
|
+
classes = [css_class]
|
|
62
|
+
else:
|
|
63
|
+
classes.insert(0, css_class)
|
|
64
|
+
|
|
65
|
+
html_id = f' id="{id_value}"' if id_value else ""
|
|
66
|
+
html_class = ' class="{}"'.format(" ".join(classes))
|
|
67
|
+
html_attrs = " " + " ".join(f'{k}="{v}"' for k, v in attrs.items()) if attrs else ""
|
|
68
|
+
|
|
69
|
+
return f"<div{html_id}{html_class}{html_attrs}>{source}</div>"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_CONVERTER = markdown.Markdown(
|
|
73
|
+
extensions=[
|
|
74
|
+
"admonition",
|
|
75
|
+
"footnotes",
|
|
76
|
+
"markdown.extensions.tables",
|
|
77
|
+
"md_in_html",
|
|
78
|
+
"pymdownx.arithmatex",
|
|
79
|
+
"pymdownx.emoji",
|
|
80
|
+
"pymdownx.highlight", # required by `pymdownx.superfences`
|
|
81
|
+
"pymdownx.magiclink",
|
|
82
|
+
"pymdownx.superfences",
|
|
83
|
+
"pymdownx.tilde",
|
|
84
|
+
"sane_lists",
|
|
85
|
+
],
|
|
86
|
+
extension_configs={
|
|
87
|
+
"footnotes": {"BACKLINK_TITLE": ""},
|
|
88
|
+
"pymdownx.arithmatex": {"generic": True, "preview": False, "tex_inline_wrap": ["", ""], "tex_block_wrap": ["", ""]},
|
|
89
|
+
"pymdownx.emoji": {"emoji_generator": _emoji_generator, "strict": True},
|
|
90
|
+
"pymdownx.highlight": {
|
|
91
|
+
"use_pygments": False,
|
|
92
|
+
},
|
|
93
|
+
"pymdownx.superfences": {
|
|
94
|
+
"custom_fences": [
|
|
95
|
+
{"name": "math", "class": "arithmatex", "format": _verbatim_formatter},
|
|
96
|
+
{"name": "csf", "class": "csf", "format": _verbatim_formatter},
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def markdown_to_html(content: str) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Converts a Markdown document into XHTML with Python-Markdown.
|
|
106
|
+
|
|
107
|
+
:param content: Markdown input as a string.
|
|
108
|
+
:returns: XHTML output as a string.
|
|
109
|
+
:see: https://python-markdown.github.io/
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_CONVERTER.reset()
|
|
113
|
+
html = _CONVERTER.convert(content)
|
|
114
|
+
return html
|
md2conf/processor.py
CHANGED
|
@@ -14,7 +14,8 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Iterable, Optional
|
|
15
15
|
|
|
16
16
|
from .collection import ConfluencePageCollection
|
|
17
|
-
from .converter import ConfluenceDocument
|
|
17
|
+
from .converter import ConfluenceDocument
|
|
18
|
+
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
18
19
|
from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
|
|
19
20
|
from .metadata import ConfluenceSiteMetadata
|
|
20
21
|
from .properties import ArgumentError
|
md2conf/toc.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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(eq=True)
|
|
14
|
+
class TableOfContentsEntry:
|
|
15
|
+
"""
|
|
16
|
+
Represents a table of contents entry.
|
|
17
|
+
|
|
18
|
+
:param level: The heading level assigned to the entry. Each entry can only contain children whose level is strictly greater than of its parent.
|
|
19
|
+
:param text: The heading text.
|
|
20
|
+
:param children: Direct descendants whose parent is this entry.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
level: int
|
|
24
|
+
text: str
|
|
25
|
+
children: list["TableOfContentsEntry"]
|
|
26
|
+
|
|
27
|
+
def __init__(self, level: int, text: str, children: Optional[list["TableOfContentsEntry"]] = None) -> None:
|
|
28
|
+
self.level = level
|
|
29
|
+
self.text = text
|
|
30
|
+
self.children = children or []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TableOfContentsBuilder:
|
|
34
|
+
"""
|
|
35
|
+
Builds a table of contents from Markdown headings.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_root: TableOfContentsEntry
|
|
39
|
+
_stack: list[TableOfContentsEntry]
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._root = TableOfContentsEntry(0, "<root>")
|
|
43
|
+
self._stack = [self._root]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def tree(self) -> list[TableOfContentsEntry]:
|
|
47
|
+
"""
|
|
48
|
+
Table of contents as a hierarchy of headings.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
return self._root.children
|
|
52
|
+
|
|
53
|
+
def add(self, level: int, text: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Adds a heading to the table of contents.
|
|
56
|
+
|
|
57
|
+
:param level: Markdown heading level (e.g. `1` for first-level heading).
|
|
58
|
+
:param text: Markdown heading text.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
if level < 1:
|
|
62
|
+
raise ValueError("expected: Markdown heading level >= 1")
|
|
63
|
+
|
|
64
|
+
# remove any stack items deeper than the current level
|
|
65
|
+
top = self._stack[-1]
|
|
66
|
+
while top.level >= level:
|
|
67
|
+
self._stack.pop()
|
|
68
|
+
top = self._stack[-1]
|
|
69
|
+
|
|
70
|
+
# add the new section under the current top level
|
|
71
|
+
item = TableOfContentsEntry(level, text)
|
|
72
|
+
top.children.append(item)
|
|
73
|
+
|
|
74
|
+
# push new level onto the stack
|
|
75
|
+
self._stack.append(item)
|
|
76
|
+
|
|
77
|
+
def get_title(self) -> Optional[str]:
|
|
78
|
+
"""
|
|
79
|
+
Returns a proposed document title.
|
|
80
|
+
|
|
81
|
+
The proposed title is text of the top-level heading if and only if that heading is unique.
|
|
82
|
+
|
|
83
|
+
:returns: Title text, or `None` if no title can be inferred.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
if len(self.tree) == 1:
|
|
87
|
+
return self.tree[0].text
|
|
88
|
+
else:
|
|
89
|
+
return None
|
md2conf/uri.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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 urllib.parse
|
|
11
|
+
import uuid
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_data_uri(mime: str, data: str) -> str:
|
|
16
|
+
"Generates a data URI with the specified MIME type."
|
|
17
|
+
|
|
18
|
+
# URL-encode data
|
|
19
|
+
encoded = urllib.parse.quote(data, safe=";/?:@&=+$,-_.!~*'()#") # minimal encoding
|
|
20
|
+
return f"data:{mime},{encoded}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_uuid(data: str) -> uuid.UUID:
|
|
24
|
+
"Generates a UUID that represents the data."
|
|
25
|
+
|
|
26
|
+
# create SHA-1 hash of the SVG content
|
|
27
|
+
sha1_hash = hashlib.sha1(data.encode("utf-8")).digest()
|
|
28
|
+
|
|
29
|
+
# generate UUID using the first 16 bytes of the hash
|
|
30
|
+
return uuid.UUID(bytes=sha1_hash[:16])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_uuid_urn(data: str) -> str:
|
|
34
|
+
"Generates a UUID URN that represents the data."
|
|
35
|
+
|
|
36
|
+
return f"urn:uuid:{str(to_uuid(data))}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_absolute_url(url: str) -> bool:
|
|
40
|
+
urlparts = urlparse(url)
|
|
41
|
+
return bool(urlparts.scheme) or bool(urlparts.netloc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_relative_url(url: str) -> bool:
|
|
45
|
+
urlparts = urlparse(url)
|
|
46
|
+
return not bool(urlparts.scheme) and not bool(urlparts.netloc)
|
md2conf/xml.py
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
|
|
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 typing import Iterable, Optional
|
|
2
10
|
|
|
3
11
|
import lxml.etree as ET
|
|
4
12
|
|
|
5
13
|
|
|
6
|
-
def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[
|
|
14
|
+
def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[str]) -> bool:
|
|
7
15
|
"""
|
|
8
16
|
Compares two dictionary objects, excluding keys in the skip set.
|
|
17
|
+
|
|
18
|
+
:param exclude: Attributes to exclude, in `{namespace}name` notation.
|
|
9
19
|
"""
|
|
10
20
|
|
|
11
21
|
# create key sets to compare, excluding keys to be skipped
|
|
@@ -23,10 +33,19 @@ def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[
|
|
|
23
33
|
|
|
24
34
|
|
|
25
35
|
class ElementComparator:
|
|
26
|
-
skip_attributes: set[
|
|
36
|
+
skip_attributes: set[str]
|
|
37
|
+
skip_elements: set[str]
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, skip_attributes: Optional[Iterable[str]] = None, skip_elements: Optional[Iterable[str]] = None):
|
|
40
|
+
"""
|
|
41
|
+
Initializes a new element tree comparator.
|
|
42
|
+
|
|
43
|
+
:param skip_attributes: Attributes to exclude, in `{namespace}name` notation.
|
|
44
|
+
:param skip_elements: Elements to exclude, in `{namespace}name` notation.
|
|
45
|
+
"""
|
|
27
46
|
|
|
28
|
-
def __init__(self, *, skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None):
|
|
29
47
|
self.skip_attributes = set(skip_attributes) if skip_attributes else set()
|
|
48
|
+
self.skip_elements = set(skip_elements) if skip_elements else set()
|
|
30
49
|
|
|
31
50
|
def is_equal(self, e1: ET._Element, e2: ET._Element) -> bool:
|
|
32
51
|
"""
|
|
@@ -36,35 +55,49 @@ class ElementComparator:
|
|
|
36
55
|
if e1.tag != e2.tag:
|
|
37
56
|
return False
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
e2_text = e2.text.strip() if e2.text else ""
|
|
41
|
-
if e1_text != e2_text:
|
|
42
|
-
return False
|
|
43
|
-
|
|
58
|
+
# compare tail first, which is outside of element
|
|
44
59
|
e1_tail = e1.tail.strip() if e1.tail else ""
|
|
45
60
|
e2_tail = e2.tail.strip() if e2.tail else ""
|
|
46
61
|
if e1_tail != e2_tail:
|
|
47
62
|
return False
|
|
48
63
|
|
|
64
|
+
# skip element (and content) if on ignore list
|
|
65
|
+
if e1.tag in self.skip_elements:
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
# compare text second, which is encapsulated by element
|
|
69
|
+
e1_text = e1.text.strip() if e1.text else ""
|
|
70
|
+
e2_text = e2.text.strip() if e2.text else ""
|
|
71
|
+
if e1_text != e2_text:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
# compare attributes, disregarding definition order
|
|
49
75
|
if not _attrs_equal_excluding(e1.attrib, e2.attrib, self.skip_attributes):
|
|
50
76
|
return False
|
|
77
|
+
|
|
78
|
+
# compare children recursively
|
|
51
79
|
if len(e1) != len(e2):
|
|
52
80
|
return False
|
|
53
81
|
return all(self.is_equal(c1, c2) for c1, c2 in zip(e1, e2))
|
|
54
82
|
|
|
55
83
|
|
|
56
84
|
def is_xml_equal(
|
|
57
|
-
tree1: ET._Element,
|
|
58
|
-
tree2: ET._Element,
|
|
59
|
-
*,
|
|
60
|
-
skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None,
|
|
85
|
+
tree1: ET._Element, tree2: ET._Element, *, skip_attributes: Optional[Iterable[str]] = None, skip_elements: Optional[Iterable[str]] = None
|
|
61
86
|
) -> bool:
|
|
62
87
|
"""
|
|
63
88
|
Compare two XML documents for equivalence, ignoring leading/trailing whitespace differences and attribute definition order.
|
|
64
89
|
|
|
65
90
|
:param tree1: XML document as an element tree.
|
|
66
91
|
:param tree2: XML document as an element tree.
|
|
92
|
+
:param skip_attributes: Attributes to exclude, in `{namespace}name` notation.
|
|
93
|
+
:param skip_elements: Elements to exclude, in `{namespace}name` notation.
|
|
67
94
|
:returns: True if equivalent, False otherwise.
|
|
68
95
|
"""
|
|
69
96
|
|
|
70
|
-
return ElementComparator(skip_attributes=skip_attributes).is_equal(tree1, tree2)
|
|
97
|
+
return ElementComparator(skip_attributes=skip_attributes, skip_elements=skip_elements).is_equal(tree1, tree2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def element_to_text(node: ET._Element) -> str:
|
|
101
|
+
"Returns all text contained in an element as a concatenated string."
|
|
102
|
+
|
|
103
|
+
return "".join(node.itertext()).strip()
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
markdown_to_confluence-0.4.2.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
2
|
-
md2conf/__init__.py,sha256=XAJAgUDrYS3PdOzoo2BQ-rM3PbQWOrOW2kPt5iJ8xY0,402
|
|
3
|
-
md2conf/__main__.py,sha256=qyboDihyVTm0EZa_c3AFWF1AojhL8-bww_kISAa5nHQ,10130
|
|
4
|
-
md2conf/api.py,sha256=DbG1udDb9ti4OjqgSW3DSuHwxKNFPVDTkhjnaB1GNMI,37193
|
|
5
|
-
md2conf/application.py,sha256=X_V4KdFACHwl5Nt4BIHQyhtecOqNOzknrPyPTW0d4Z0,8185
|
|
6
|
-
md2conf/collection.py,sha256=EobgMRJgkYloWlY03NZJ52MRC_SGLpTVCHkltDbQyt0,837
|
|
7
|
-
md2conf/converter.py,sha256=Eg6emS77GQAkhbXutaRjHxcBFgmXp_4z_zOAmBfqxUY,54360
|
|
8
|
-
md2conf/drawio.py,sha256=G_pD2nafl7dXuFK_4MBiEUl0ZZGNuagnHw6GFOrev94,6717
|
|
9
|
-
md2conf/emoji.py,sha256=UzDrxqFo59wHmbbJmMNdn0rYFDXbZE4qirOM-_egzXc,2603
|
|
10
|
-
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
11
|
-
md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
|
|
12
|
-
md2conf/local.py,sha256=Ph-cGc_JQ1SkvuZ_Jxn37dlpaKZYKLVPBnsk5CGSVnk,3548
|
|
13
|
-
md2conf/matcher.py,sha256=m5rZjYZSjhKfdeKS8JdPq7cG861Mc6rVZBkrIOZTHGE,6916
|
|
14
|
-
md2conf/mermaid.py,sha256=f0x7ISj-41ZMh4zTAFPhIWwr94SDcsVZUc1NWqmH_G4,2508
|
|
15
|
-
md2conf/metadata.py,sha256=LzZM-oPNnzCULmLhF516tPlV5zZBknccwMHt8Nan-xg,1007
|
|
16
|
-
md2conf/processor.py,sha256=59XDWKgTvJwEZ1y52VfRkM67K2-Ivh7kGD6Eg2tfG9c,9713
|
|
17
|
-
md2conf/properties.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
|
|
18
|
-
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
19
|
-
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
-
md2conf/scanner.py,sha256=Cyvjab8tBvKgubttQvNagS8nailuTvFBqUGoiX5MNp8,5351
|
|
21
|
-
md2conf/xml.py,sha256=HoKJfF1yRZ3Gk8jTS-kRpOqVs0nQJZyr56l0Fo3y9fs,2193
|
|
22
|
-
markdown_to_confluence-0.4.2.dist-info/METADATA,sha256=ggIrg30RRTvk3ax2LKC_wKjMhOEhrXOYVrSwvb2dDbw,29495
|
|
23
|
-
markdown_to_confluence-0.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
24
|
-
markdown_to_confluence-0.4.2.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
25
|
-
markdown_to_confluence-0.4.2.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
26
|
-
markdown_to_confluence-0.4.2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
27
|
-
markdown_to_confluence-0.4.2.dist-info/RECORD,,
|