markdown-to-confluence 0.4.1__py3-none-any.whl → 0.4.2__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.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/METADATA +101 -33
- markdown_to_confluence-0.4.2.dist-info/RECORD +27 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +53 -6
- md2conf/api.py +84 -94
- md2conf/application.py +17 -2
- md2conf/collection.py +17 -11
- md2conf/converter.py +477 -58
- md2conf/drawio.py +222 -0
- md2conf/local.py +1 -0
- md2conf/matcher.py +63 -4
- md2conf/metadata.py +2 -0
- md2conf/processor.py +37 -28
- md2conf/scanner.py +7 -0
- md2conf/xml.py +70 -0
- markdown_to_confluence-0.4.1.dist-info/RECORD +0 -25
- {markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/zip-safe +0 -0
md2conf/drawio.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
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 base64
|
|
10
|
+
import typing
|
|
11
|
+
import zlib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from struct import unpack
|
|
14
|
+
from urllib.parse import unquote_to_bytes
|
|
15
|
+
|
|
16
|
+
import lxml.etree as ET
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DrawioError(ValueError):
|
|
20
|
+
"""
|
|
21
|
+
Raised when the input does not adhere to the draw.io document format, or processing the input into a draw.io diagram fails.
|
|
22
|
+
|
|
23
|
+
Examples include:
|
|
24
|
+
|
|
25
|
+
* invalid or corrupt PNG file
|
|
26
|
+
* PNG chunk with embedded diagram data not found
|
|
27
|
+
* the structure of the outer XML does not match the expected format
|
|
28
|
+
* URL decoding error
|
|
29
|
+
* decompression error during INFLATE
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inflate(data: bytes) -> bytes:
|
|
34
|
+
"""
|
|
35
|
+
Decompresses (inflates) data compressed using the raw DEFLATE algorithm.
|
|
36
|
+
|
|
37
|
+
:param data: Compressed data using raw DEFLATE format.
|
|
38
|
+
:returns: Uncompressed data.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# -zlib.MAX_WBITS indicates raw DEFLATE stream (no zlib/gzip headers)
|
|
42
|
+
return zlib.decompress(data, -zlib.MAX_WBITS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decompress_diagram(xml_data: typing.Union[bytes, str]) -> ET._Element:
|
|
46
|
+
"""
|
|
47
|
+
Decompresses the text content of the `<diagram>` element in a draw.io XML document.
|
|
48
|
+
|
|
49
|
+
If the data is not compressed, the de-serialized XML element tree is returned.
|
|
50
|
+
|
|
51
|
+
Expected input (as `bytes` or `str`):
|
|
52
|
+
```
|
|
53
|
+
<mxfile>
|
|
54
|
+
<diagram>... ENCODED_COMPRESSED_DATA ...</diagram>
|
|
55
|
+
</mxfile>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Output (as XML element tree):
|
|
59
|
+
```
|
|
60
|
+
<mxfile>
|
|
61
|
+
<diagram>
|
|
62
|
+
<mxGraphModel>
|
|
63
|
+
<root>
|
|
64
|
+
...
|
|
65
|
+
</root>
|
|
66
|
+
</mxGraphModel>
|
|
67
|
+
</diagram>
|
|
68
|
+
</mxfile>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
:param xml_data: The serialized XML document.
|
|
72
|
+
:returns: XML element tree with the text contained within the `<diagram>` element expanded into a sub-tree.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
root = ET.fromstring(xml_data)
|
|
77
|
+
except ET.ParseError as e:
|
|
78
|
+
raise DrawioError("invalid outer XML") from e
|
|
79
|
+
|
|
80
|
+
if root.tag != "mxfile":
|
|
81
|
+
raise DrawioError("root element is not `<mxfile>`")
|
|
82
|
+
|
|
83
|
+
diagram_elem = root.find("diagram")
|
|
84
|
+
if diagram_elem is None:
|
|
85
|
+
raise DrawioError("`<diagram>` element not found")
|
|
86
|
+
|
|
87
|
+
if len(diagram_elem) > 0:
|
|
88
|
+
# already decompressed
|
|
89
|
+
return root
|
|
90
|
+
|
|
91
|
+
if diagram_elem.text is None:
|
|
92
|
+
raise DrawioError("`<diagram>` element has no data")
|
|
93
|
+
|
|
94
|
+
# reverse base64-encoding of inner data
|
|
95
|
+
try:
|
|
96
|
+
base64_decoded = base64.b64decode(diagram_elem.text, validate=True)
|
|
97
|
+
except ValueError as e:
|
|
98
|
+
raise DrawioError("raw text data in `<diagram>` element is not properly Base64-encoded") from e
|
|
99
|
+
|
|
100
|
+
# decompress inner data
|
|
101
|
+
try:
|
|
102
|
+
embedded_data = inflate(base64_decoded)
|
|
103
|
+
except zlib.error as e:
|
|
104
|
+
raise DrawioError("`<diagram>` element text data cannot be decompressed using INFLATE") from e
|
|
105
|
+
|
|
106
|
+
# reverse URL-encoding of inner data
|
|
107
|
+
try:
|
|
108
|
+
url_decoded = unquote_to_bytes(embedded_data)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
raise DrawioError("decompressed data in `<diagram>` element is not properly URL-encoded") from e
|
|
111
|
+
|
|
112
|
+
# create sub-tree from decompressed data
|
|
113
|
+
try:
|
|
114
|
+
tree = ET.fromstring(url_decoded)
|
|
115
|
+
except ET.ParseError as e:
|
|
116
|
+
raise DrawioError("invalid inner XML extracted from `<diagram>` element") from e
|
|
117
|
+
|
|
118
|
+
# update document
|
|
119
|
+
diagram_elem.text = None
|
|
120
|
+
diagram_elem.append(tree)
|
|
121
|
+
|
|
122
|
+
return root
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_xml_from_png(png_data: bytes) -> ET._Element:
|
|
126
|
+
"""
|
|
127
|
+
Extracts an editable draw.io diagram from a PNG file.
|
|
128
|
+
|
|
129
|
+
:param png_data: PNG binary data, with an embedded draw.io diagram.
|
|
130
|
+
:returns: XML element tree of a draw.io diagram.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
# PNG signature is always the first 8 bytes
|
|
134
|
+
png_signature = b"\x89PNG\r\n\x1a\n"
|
|
135
|
+
if not png_data.startswith(png_signature):
|
|
136
|
+
raise DrawioError("not a valid PNG file")
|
|
137
|
+
|
|
138
|
+
offset = len(png_signature)
|
|
139
|
+
while offset < len(png_data):
|
|
140
|
+
if offset + 8 > len(png_data):
|
|
141
|
+
raise DrawioError("corrupted PNG: incomplete chunk header")
|
|
142
|
+
|
|
143
|
+
# read chunk length (4 bytes) and type (4 bytes)
|
|
144
|
+
(length,) = unpack(">I", png_data[offset : offset + 4])
|
|
145
|
+
chunk_type = png_data[offset + 4 : offset + 8]
|
|
146
|
+
offset += 8
|
|
147
|
+
|
|
148
|
+
if offset + length + 4 > len(png_data):
|
|
149
|
+
raise DrawioError(f"corrupted PNG: incomplete data for chunk {chunk_type.decode('ascii')}")
|
|
150
|
+
|
|
151
|
+
# read chunk data
|
|
152
|
+
chunk_data = png_data[offset : offset + length]
|
|
153
|
+
offset += length
|
|
154
|
+
|
|
155
|
+
# skip CRC (4 bytes)
|
|
156
|
+
offset += 4
|
|
157
|
+
|
|
158
|
+
# extracts draw.io diagram data from a `tEXt` chunk with the keyword `mxfile` embedded in a PNG
|
|
159
|
+
if chunk_type != b"tEXt":
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# format: keyword\0text
|
|
163
|
+
null_pos = chunk_data.find(b"\x00")
|
|
164
|
+
if null_pos < 0:
|
|
165
|
+
raise DrawioError("corrupted PNG: tEXt chunk missing keyword")
|
|
166
|
+
|
|
167
|
+
keyword = chunk_data[:null_pos].decode("latin1")
|
|
168
|
+
if keyword != "mxfile":
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
textual_data = chunk_data[null_pos + 1 :]
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
url_decoded = unquote_to_bytes(textual_data)
|
|
175
|
+
except ValueError as e:
|
|
176
|
+
raise DrawioError("data in `tEXt` chunk is not properly URL-encoded") from e
|
|
177
|
+
|
|
178
|
+
# decompress data embedded in the outer XML wrapper
|
|
179
|
+
return decompress_diagram(url_decoded)
|
|
180
|
+
|
|
181
|
+
# matching `tEXt` chunk not found
|
|
182
|
+
raise DrawioError("not a PNG file made with draw.io")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def extract_xml_from_svg(svg_data: bytes) -> ET._Element:
|
|
186
|
+
"""
|
|
187
|
+
Extracts an editable draw.io diagram from an SVG file.
|
|
188
|
+
|
|
189
|
+
:param svg_data: SVG XML data, with an embedded draw.io diagram.
|
|
190
|
+
:returns: XML element tree of a draw.io diagram.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
root = ET.fromstring(svg_data)
|
|
195
|
+
except ET.ParseError as e:
|
|
196
|
+
raise DrawioError("invalid SVG XML") from e
|
|
197
|
+
|
|
198
|
+
content = root.attrib.get("content")
|
|
199
|
+
if content is None:
|
|
200
|
+
raise DrawioError("SVG root element has no attribute `content`")
|
|
201
|
+
|
|
202
|
+
return decompress_diagram(content)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def extract_diagram(path: Path) -> bytes:
|
|
206
|
+
"""
|
|
207
|
+
Extracts an editable draw.io diagram from a PNG file.
|
|
208
|
+
|
|
209
|
+
:param path: Path to a PNG or SVG file with an embedded draw.io diagram.
|
|
210
|
+
:returns: XML data of a draw.io diagram as bytes.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
if path.name.endswith(".drawio.png"):
|
|
214
|
+
with open(path, "rb") as png_file:
|
|
215
|
+
root = extract_xml_from_png(png_file.read())
|
|
216
|
+
elif path.name.endswith(".drawio.svg"):
|
|
217
|
+
with open(path, "rb") as svg_file:
|
|
218
|
+
root = extract_xml_from_svg(svg_file.read())
|
|
219
|
+
else:
|
|
220
|
+
raise DrawioError(f"unrecognized file type for {path.name}")
|
|
221
|
+
|
|
222
|
+
return ET.tostring(root, encoding="utf8", method="xml")
|
md2conf/local.py
CHANGED
md2conf/matcher.py
CHANGED
|
@@ -10,14 +10,57 @@ import os.path
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from fnmatch import fnmatch
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Iterable, Optional, Union, overload
|
|
13
|
+
from typing import Iterable, Optional, Union, final, overload
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
@dataclass(frozen=True)
|
|
16
|
+
@dataclass(frozen=True, eq=True)
|
|
17
|
+
class _BaseEntry:
|
|
18
|
+
"""
|
|
19
|
+
Represents a file or directory entry.
|
|
20
|
+
|
|
21
|
+
Entries are primarily sorted alphabetically case-insensitive.
|
|
22
|
+
When two items are equal case-insensitive, conflicting items are put in case-sensitive order.
|
|
23
|
+
|
|
24
|
+
:param name: Name of the file-system entry.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def lower_name(self) -> str:
|
|
31
|
+
return self.name.lower()
|
|
32
|
+
|
|
33
|
+
def __lt__(self, other: "_BaseEntry") -> bool:
|
|
34
|
+
return (self.lower_name, self.name) < (other.lower_name, other.name)
|
|
35
|
+
|
|
36
|
+
def __le__(self, other: "_BaseEntry") -> bool:
|
|
37
|
+
return (self.lower_name, self.name) <= (other.lower_name, other.name)
|
|
38
|
+
|
|
39
|
+
def __ge__(self, other: "_BaseEntry") -> bool:
|
|
40
|
+
return (self.lower_name, self.name) >= (other.lower_name, other.name)
|
|
41
|
+
|
|
42
|
+
def __gt__(self, other: "_BaseEntry") -> bool:
|
|
43
|
+
return (self.lower_name, self.name) > (other.lower_name, other.name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@final
|
|
47
|
+
class FileEntry(_BaseEntry):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@final
|
|
52
|
+
class DirectoryEntry(_BaseEntry):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, eq=True)
|
|
17
57
|
class Entry:
|
|
18
58
|
"""
|
|
19
59
|
Represents a file or directory entry.
|
|
20
60
|
|
|
61
|
+
When sorted, directories come before files and items are primarily arranged in alphabetical order case-insensitive.
|
|
62
|
+
When two items are equal case-insensitive, conflicting items are put in case-sensitive order.
|
|
63
|
+
|
|
21
64
|
:param name: Name of the file-system entry to match against the rule-set.
|
|
22
65
|
:param is_dir: True if the entry is a directory.
|
|
23
66
|
"""
|
|
@@ -25,6 +68,22 @@ class Entry:
|
|
|
25
68
|
name: str
|
|
26
69
|
is_dir: bool
|
|
27
70
|
|
|
71
|
+
@property
|
|
72
|
+
def lower_name(self) -> str:
|
|
73
|
+
return self.name.lower()
|
|
74
|
+
|
|
75
|
+
def __lt__(self, other: "Entry") -> bool:
|
|
76
|
+
return (not self.is_dir, self.lower_name, self.name) < (not other.is_dir, other.lower_name, other.name)
|
|
77
|
+
|
|
78
|
+
def __le__(self, other: "Entry") -> bool:
|
|
79
|
+
return (not self.is_dir, self.lower_name, self.name) <= (not other.is_dir, other.lower_name, other.name)
|
|
80
|
+
|
|
81
|
+
def __ge__(self, other: "Entry") -> bool:
|
|
82
|
+
return (not self.is_dir, self.lower_name, self.name) >= (not other.is_dir, other.lower_name, other.name)
|
|
83
|
+
|
|
84
|
+
def __gt__(self, other: "Entry") -> bool:
|
|
85
|
+
return (not self.is_dir, self.lower_name, self.name) > (not other.is_dir, other.lower_name, other.name)
|
|
86
|
+
|
|
28
87
|
|
|
29
88
|
@dataclass
|
|
30
89
|
class MatcherOptions:
|
|
@@ -146,9 +205,9 @@ class Matcher:
|
|
|
146
205
|
:returns: A filtered list of names that didn't match any of the exclusion rules.
|
|
147
206
|
"""
|
|
148
207
|
|
|
149
|
-
return
|
|
208
|
+
return sorted(entry for entry in entries if self.is_included(entry))
|
|
150
209
|
|
|
151
|
-
def
|
|
210
|
+
def listing(self, path: Path) -> list[Entry]:
|
|
152
211
|
"""
|
|
153
212
|
Returns only those entries in a directory whose name doesn't match any of the exclusion rules.
|
|
154
213
|
|
md2conf/metadata.py
CHANGED
|
@@ -33,8 +33,10 @@ 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 synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
36
37
|
"""
|
|
37
38
|
|
|
38
39
|
page_id: str
|
|
39
40
|
space_key: str
|
|
40
41
|
title: str
|
|
42
|
+
synchronized: bool
|
md2conf/processor.py
CHANGED
|
@@ -15,7 +15,7 @@ from typing import Iterable, Optional
|
|
|
15
15
|
|
|
16
16
|
from .collection import ConfluencePageCollection
|
|
17
17
|
from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
|
|
18
|
-
from .matcher import Matcher, MatcherOptions
|
|
18
|
+
from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
|
|
19
19
|
from .metadata import ConfluenceSiteMetadata
|
|
20
20
|
from .properties import ArgumentError
|
|
21
21
|
from .scanner import Scanner
|
|
@@ -28,6 +28,7 @@ class DocumentNode:
|
|
|
28
28
|
page_id: Optional[str]
|
|
29
29
|
space_key: Optional[str]
|
|
30
30
|
title: Optional[str]
|
|
31
|
+
synchronized: bool
|
|
31
32
|
|
|
32
33
|
_children: list["DocumentNode"]
|
|
33
34
|
|
|
@@ -35,13 +36,15 @@ class DocumentNode:
|
|
|
35
36
|
self,
|
|
36
37
|
absolute_path: Path,
|
|
37
38
|
page_id: Optional[str],
|
|
38
|
-
space_key: Optional[str]
|
|
39
|
-
title: Optional[str]
|
|
39
|
+
space_key: Optional[str],
|
|
40
|
+
title: Optional[str],
|
|
41
|
+
synchronized: bool,
|
|
40
42
|
):
|
|
41
43
|
self.absolute_path = absolute_path
|
|
42
44
|
self.page_id = page_id
|
|
43
45
|
self.space_key = space_key
|
|
44
46
|
self.title = title
|
|
47
|
+
self.synchronized = synchronized
|
|
45
48
|
self._children = []
|
|
46
49
|
|
|
47
50
|
def count(self) -> int:
|
|
@@ -98,16 +101,11 @@ class Processor:
|
|
|
98
101
|
local_dir = local_dir.resolve(True)
|
|
99
102
|
LOGGER.info("Processing directory: %s", local_dir)
|
|
100
103
|
|
|
101
|
-
#
|
|
104
|
+
# build index of all Markdown files in directory hierarchy
|
|
102
105
|
root = self._index_directory(local_dir, None)
|
|
103
106
|
LOGGER.info("Indexed %d document(s)", root.count())
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
self._synchronize_tree(root, self.options.root_page_id)
|
|
107
|
-
|
|
108
|
-
# Step 3: synchronize files in directory hierarchy with pages in space
|
|
109
|
-
for path, metadata in self.page_metadata.items():
|
|
110
|
-
self._synchronize_page(path, ConfluencePageID(metadata.page_id))
|
|
108
|
+
self._process_items(root)
|
|
111
109
|
|
|
112
110
|
def process_page(self, path: Path) -> None:
|
|
113
111
|
"""
|
|
@@ -115,16 +113,22 @@ class Processor:
|
|
|
115
113
|
"""
|
|
116
114
|
|
|
117
115
|
LOGGER.info("Processing page: %s", path)
|
|
118
|
-
|
|
119
|
-
# Step 1: parse Markdown file
|
|
120
116
|
root = self._index_file(path)
|
|
121
117
|
|
|
122
|
-
|
|
118
|
+
self._process_items(root)
|
|
119
|
+
|
|
120
|
+
def _process_items(self, root: DocumentNode) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Processes a sub-tree rooted at an ancestor node.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
# synchronize directory tree structure with page hierarchy in space (find matching pages in Confluence)
|
|
123
126
|
self._synchronize_tree(root, self.options.root_page_id)
|
|
124
127
|
|
|
125
|
-
#
|
|
128
|
+
# synchronize files in directory hierarchy with pages in space
|
|
126
129
|
for path, metadata in self.page_metadata.items():
|
|
127
|
-
|
|
130
|
+
if metadata.synchronized:
|
|
131
|
+
self._synchronize_page(path, ConfluencePageID(metadata.page_id))
|
|
128
132
|
|
|
129
133
|
def _synchronize_page(self, path: Path, page_id: ConfluencePageID) -> None:
|
|
130
134
|
"""
|
|
@@ -161,36 +165,40 @@ class Processor:
|
|
|
161
165
|
|
|
162
166
|
matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
|
|
163
167
|
|
|
164
|
-
files: list[
|
|
165
|
-
directories: list[
|
|
168
|
+
files: list[FileEntry] = []
|
|
169
|
+
directories: list[DirectoryEntry] = []
|
|
166
170
|
for entry in os.scandir(local_dir):
|
|
167
171
|
if matcher.is_excluded(entry):
|
|
168
172
|
continue
|
|
169
173
|
|
|
170
174
|
if entry.is_file():
|
|
171
|
-
files.append(
|
|
175
|
+
files.append(FileEntry(entry.name))
|
|
172
176
|
elif entry.is_dir():
|
|
173
|
-
directories.append(
|
|
177
|
+
directories.append(DirectoryEntry(entry.name))
|
|
178
|
+
|
|
179
|
+
files.sort()
|
|
180
|
+
directories.sort()
|
|
174
181
|
|
|
175
182
|
# make page act as parent node
|
|
176
183
|
parent_doc: Optional[Path] = None
|
|
177
|
-
if (
|
|
184
|
+
if FileEntry("index.md") in files:
|
|
178
185
|
parent_doc = local_dir / "index.md"
|
|
179
|
-
elif (
|
|
186
|
+
elif FileEntry("README.md") in files:
|
|
180
187
|
parent_doc = local_dir / "README.md"
|
|
181
|
-
elif (
|
|
188
|
+
elif FileEntry(f"{local_dir.name}.md") in files:
|
|
182
189
|
parent_doc = local_dir / f"{local_dir.name}.md"
|
|
183
190
|
|
|
184
191
|
if parent_doc is None and self.options.keep_hierarchy:
|
|
185
192
|
parent_doc = local_dir / "index.md"
|
|
186
193
|
|
|
187
194
|
# create a blank page for directory entry
|
|
188
|
-
with open(parent_doc, "w"):
|
|
189
|
-
|
|
195
|
+
with open(parent_doc, "w") as f:
|
|
196
|
+
print("[[_LISTING_]]", file=f)
|
|
190
197
|
|
|
191
198
|
if parent_doc is not None:
|
|
192
|
-
|
|
193
|
-
|
|
199
|
+
parent_entry = FileEntry(parent_doc.name)
|
|
200
|
+
if parent_entry in files:
|
|
201
|
+
files.remove(parent_entry)
|
|
194
202
|
|
|
195
203
|
# promote Markdown document in directory as parent page in Confluence
|
|
196
204
|
node = self._index_file(parent_doc)
|
|
@@ -201,11 +209,11 @@ class Processor:
|
|
|
201
209
|
raise ArgumentError(f"root page requires corresponding top-level Markdown document in {local_dir}")
|
|
202
210
|
|
|
203
211
|
for file in files:
|
|
204
|
-
node = self._index_file(file)
|
|
212
|
+
node = self._index_file(local_dir / Path(file.name))
|
|
205
213
|
parent.add_child(node)
|
|
206
214
|
|
|
207
215
|
for directory in directories:
|
|
208
|
-
self._index_directory(directory, parent)
|
|
216
|
+
self._index_directory(local_dir / Path(directory.name), parent)
|
|
209
217
|
|
|
210
218
|
return parent
|
|
211
219
|
|
|
@@ -224,6 +232,7 @@ class Processor:
|
|
|
224
232
|
page_id=document.page_id,
|
|
225
233
|
space_key=document.space_key,
|
|
226
234
|
title=document.title,
|
|
235
|
+
synchronized=document.synchronized if document.synchronized is not None else True,
|
|
227
236
|
)
|
|
228
237
|
|
|
229
238
|
def _generate_hash(self, absolute_path: Path) -> str:
|
md2conf/scanner.py
CHANGED
|
@@ -69,6 +69,7 @@ class DocumentProperties:
|
|
|
69
69
|
:param generated_by: Text identifying the tool that generated the document.
|
|
70
70
|
:param title: The title extracted from front-matter.
|
|
71
71
|
:param tags: A list of tags (content labels) extracted from front-matter.
|
|
72
|
+
:param synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
72
73
|
:param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
|
|
73
74
|
"""
|
|
74
75
|
|
|
@@ -79,6 +80,7 @@ class DocumentProperties:
|
|
|
79
80
|
generated_by: Optional[str]
|
|
80
81
|
title: Optional[str]
|
|
81
82
|
tags: Optional[list[str]]
|
|
83
|
+
synchronized: Optional[bool]
|
|
82
84
|
properties: Optional[dict[str, JsonType]]
|
|
83
85
|
|
|
84
86
|
|
|
@@ -92,6 +94,7 @@ class ScannedDocument:
|
|
|
92
94
|
:param generated_by: Text identifying the tool that generated the document.
|
|
93
95
|
:param title: The title extracted from front-matter.
|
|
94
96
|
:param tags: A list of tags (content labels) extracted from front-matter.
|
|
97
|
+
:param synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
95
98
|
:param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
|
|
96
99
|
:param text: Text that remains after front-matter and inline properties have been extracted.
|
|
97
100
|
"""
|
|
@@ -101,6 +104,7 @@ class ScannedDocument:
|
|
|
101
104
|
generated_by: Optional[str]
|
|
102
105
|
title: Optional[str]
|
|
103
106
|
tags: Optional[list[str]]
|
|
107
|
+
synchronized: Optional[bool]
|
|
104
108
|
properties: Optional[dict[str, JsonType]]
|
|
105
109
|
text: str
|
|
106
110
|
|
|
@@ -126,6 +130,7 @@ class Scanner:
|
|
|
126
130
|
|
|
127
131
|
title: Optional[str] = None
|
|
128
132
|
tags: Optional[list[str]] = None
|
|
133
|
+
synchronized: Optional[bool] = None
|
|
129
134
|
properties: Optional[dict[str, JsonType]] = None
|
|
130
135
|
|
|
131
136
|
# extract front-matter
|
|
@@ -137,6 +142,7 @@ class Scanner:
|
|
|
137
142
|
generated_by = generated_by or p.generated_by
|
|
138
143
|
title = p.title
|
|
139
144
|
tags = p.tags
|
|
145
|
+
synchronized = p.synchronized
|
|
140
146
|
properties = p.properties
|
|
141
147
|
|
|
142
148
|
return ScannedDocument(
|
|
@@ -145,6 +151,7 @@ class Scanner:
|
|
|
145
151
|
generated_by=generated_by,
|
|
146
152
|
title=title,
|
|
147
153
|
tags=tags,
|
|
154
|
+
synchronized=synchronized,
|
|
148
155
|
properties=properties,
|
|
149
156
|
text=text,
|
|
150
157
|
)
|
md2conf/xml.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Iterable, Optional, Union
|
|
2
|
+
|
|
3
|
+
import lxml.etree as ET
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[Union[str, ET.QName]]) -> bool:
|
|
7
|
+
"""
|
|
8
|
+
Compares two dictionary objects, excluding keys in the skip set.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# create key sets to compare, excluding keys to be skipped
|
|
12
|
+
keys1 = {k for k in attrs1.keys() if k not in exclude}
|
|
13
|
+
keys2 = {k for k in attrs2.keys() if k not in exclude}
|
|
14
|
+
if keys1 != keys2:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
# compare values for each key
|
|
18
|
+
for key in keys1:
|
|
19
|
+
if attrs1.get(key) != attrs2.get(key):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ElementComparator:
|
|
26
|
+
skip_attributes: set[Union[str, ET.QName]]
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None):
|
|
29
|
+
self.skip_attributes = set(skip_attributes) if skip_attributes else set()
|
|
30
|
+
|
|
31
|
+
def is_equal(self, e1: ET._Element, e2: ET._Element) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
Recursively check if two XML elements are equal.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
if e1.tag != e2.tag:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
e1_text = e1.text.strip() if e1.text else ""
|
|
40
|
+
e2_text = e2.text.strip() if e2.text else ""
|
|
41
|
+
if e1_text != e2_text:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
e1_tail = e1.tail.strip() if e1.tail else ""
|
|
45
|
+
e2_tail = e2.tail.strip() if e2.tail else ""
|
|
46
|
+
if e1_tail != e2_tail:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
if not _attrs_equal_excluding(e1.attrib, e2.attrib, self.skip_attributes):
|
|
50
|
+
return False
|
|
51
|
+
if len(e1) != len(e2):
|
|
52
|
+
return False
|
|
53
|
+
return all(self.is_equal(c1, c2) for c1, c2 in zip(e1, e2))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_xml_equal(
|
|
57
|
+
tree1: ET._Element,
|
|
58
|
+
tree2: ET._Element,
|
|
59
|
+
*,
|
|
60
|
+
skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None,
|
|
61
|
+
) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Compare two XML documents for equivalence, ignoring leading/trailing whitespace differences and attribute definition order.
|
|
64
|
+
|
|
65
|
+
:param tree1: XML document as an element tree.
|
|
66
|
+
:param tree2: XML document as an element tree.
|
|
67
|
+
:returns: True if equivalent, False otherwise.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
return ElementComparator(skip_attributes=skip_attributes).is_equal(tree1, tree2)
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
markdown_to_confluence-0.4.1.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
2
|
-
md2conf/__init__.py,sha256=K6ZE42N5KJjN5o2GqIFa_lcPZvMMCXPMMRWEkvlmcp0,402
|
|
3
|
-
md2conf/__main__.py,sha256=MJm9U75savKWKYm4dLREqlsyBWEqkTtaM4YTWkEeo0E,8388
|
|
4
|
-
md2conf/api.py,sha256=RQ_nb0Z0VnhJma1BU9ABeb4MQZvZEfFS5mTXXKcY6bk,37584
|
|
5
|
-
md2conf/application.py,sha256=cXYXYdEdmMXwhxF69eUiPPG2Ixt4xtlWHXa28wTq150,7571
|
|
6
|
-
md2conf/collection.py,sha256=EAXuIFcIRBO-Giic2hdU2d4Hpj0_ZFBAWI3aKQ2fjrI,775
|
|
7
|
-
md2conf/converter.py,sha256=x2LAY1Hpw5mTVFNJE5_Zm-o7p5y6TTds6KfrpdM5qQk,38823
|
|
8
|
-
md2conf/emoji.py,sha256=UzDrxqFo59wHmbbJmMNdn0rYFDXbZE4qirOM-_egzXc,2603
|
|
9
|
-
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
10
|
-
md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
|
|
11
|
-
md2conf/local.py,sha256=MVwGxy_n00uqCInLK8FVGaaVnaOp1nfn28PVrWz3cCQ,3496
|
|
12
|
-
md2conf/matcher.py,sha256=izgL_MAMqbXjKPvAz3KpFv5OTDsaJ9GplTJbixrT3oY,4918
|
|
13
|
-
md2conf/mermaid.py,sha256=f0x7ISj-41ZMh4zTAFPhIWwr94SDcsVZUc1NWqmH_G4,2508
|
|
14
|
-
md2conf/metadata.py,sha256=TxgUrskqsWor_pvlQx-p86C0-0qRJ2aeQhuDcXU9Dpc,886
|
|
15
|
-
md2conf/processor.py,sha256=yWVRYtbc9UHSUfRxqyPDsgeVqO7gx0s3RiGL1GzMotE,9405
|
|
16
|
-
md2conf/properties.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
|
|
17
|
-
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
18
|
-
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
-
md2conf/scanner.py,sha256=qXfnJkaEwDbz6G6Z9llqifBp2TLAlrXAIP4qkCbGdWo,4964
|
|
20
|
-
markdown_to_confluence-0.4.1.dist-info/METADATA,sha256=rAXtL2mR1LHmc_pwkmnwrGpIDMEw-7kZjIJOnMi-NLA,24864
|
|
21
|
-
markdown_to_confluence-0.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
22
|
-
markdown_to_confluence-0.4.1.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
23
|
-
markdown_to_confluence-0.4.1.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
24
|
-
markdown_to_confluence-0.4.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
25
|
-
markdown_to_confluence-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
{markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{markdown_to_confluence-0.4.1.dist-info → markdown_to_confluence-0.4.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|