markdown-to-confluence 0.5.2__py3-none-any.whl → 0.5.3__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.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +80 -4
- markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +42 -24
- md2conf/api.py +27 -8
- md2conf/attachment.py +72 -0
- md2conf/coalesce.py +43 -0
- md2conf/collection.py +1 -1
- md2conf/{extra.py → compatibility.py} +1 -1
- md2conf/converter.py +232 -649
- md2conf/csf.py +13 -11
- md2conf/drawio/__init__.py +0 -0
- md2conf/drawio/extension.py +116 -0
- md2conf/{drawio.py → drawio/render.py} +1 -1
- md2conf/emoticon.py +3 -3
- md2conf/environment.py +2 -2
- md2conf/extension.py +78 -0
- md2conf/external.py +49 -0
- md2conf/formatting.py +135 -0
- md2conf/frontmatter.py +70 -0
- md2conf/image.py +127 -0
- md2conf/latex.py +4 -183
- md2conf/local.py +8 -8
- md2conf/markdown.py +1 -1
- md2conf/matcher.py +1 -1
- md2conf/mermaid/__init__.py +0 -0
- md2conf/mermaid/config.py +20 -0
- md2conf/mermaid/extension.py +109 -0
- md2conf/{mermaid.py → mermaid/render.py} +10 -38
- md2conf/mermaid/scanner.py +55 -0
- md2conf/metadata.py +1 -1
- md2conf/{domain.py → options.py} +73 -16
- md2conf/plantuml/__init__.py +0 -0
- md2conf/plantuml/config.py +20 -0
- md2conf/plantuml/extension.py +158 -0
- md2conf/plantuml/render.py +139 -0
- md2conf/plantuml/scanner.py +56 -0
- md2conf/png.py +202 -0
- md2conf/processor.py +32 -11
- md2conf/publisher.py +14 -18
- md2conf/scanner.py +31 -128
- md2conf/serializer.py +2 -2
- md2conf/svg.py +24 -2
- md2conf/text.py +1 -1
- md2conf/toc.py +1 -1
- md2conf/uri.py +1 -1
- md2conf/xml.py +1 -1
- markdown_to_confluence-0.5.2.dist-info/RECORD +0 -36
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/png.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from struct import unpack
|
|
12
|
+
from typing import BinaryIO, Iterable, overload
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Chunk:
|
|
16
|
+
"Data chunk in binary data as per the PNG image format."
|
|
17
|
+
|
|
18
|
+
__slots__ = ("length", "name", "data", "crc")
|
|
19
|
+
|
|
20
|
+
length: int
|
|
21
|
+
name: bytes
|
|
22
|
+
data: bytes
|
|
23
|
+
crc: bytes
|
|
24
|
+
|
|
25
|
+
def __init__(self, length: int, name: bytes, data: bytes, crc: bytes):
|
|
26
|
+
self.length = length
|
|
27
|
+
self.name = name
|
|
28
|
+
self.data = data
|
|
29
|
+
self.crc = crc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_signature(f: BinaryIO) -> None:
|
|
33
|
+
"Reads and checks PNG signature (first 8 bytes)."
|
|
34
|
+
|
|
35
|
+
signature = f.read(8)
|
|
36
|
+
if signature != b"\x89PNG\r\n\x1a\n":
|
|
37
|
+
raise ValueError("not a valid PNG file")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_chunk(f: BinaryIO) -> _Chunk | None:
|
|
41
|
+
"Reads and parses a PNG chunk such as `IHDR` or `tEXt`."
|
|
42
|
+
|
|
43
|
+
length_bytes = f.read(4)
|
|
44
|
+
if not length_bytes:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
if len(length_bytes) != 4:
|
|
48
|
+
raise ValueError("expected: 4 bytes storing chunk length")
|
|
49
|
+
|
|
50
|
+
length = int.from_bytes(length_bytes, "big")
|
|
51
|
+
|
|
52
|
+
data_length = 4 + length + 4
|
|
53
|
+
data_bytes = f.read(data_length)
|
|
54
|
+
actual_length = len(data_bytes)
|
|
55
|
+
if actual_length != data_length:
|
|
56
|
+
raise ValueError(f"expected: {length} bytes storing chunk data; got: {actual_length}")
|
|
57
|
+
|
|
58
|
+
chunk_type = data_bytes[0:4]
|
|
59
|
+
chunk_data = data_bytes[4:-4]
|
|
60
|
+
crc = data_bytes[-4:]
|
|
61
|
+
|
|
62
|
+
return _Chunk(length, chunk_type, chunk_data, crc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_png_dimensions(source_file: BinaryIO) -> tuple[int, int]:
|
|
66
|
+
"""
|
|
67
|
+
Returns the width and height of a PNG image inspecting its header.
|
|
68
|
+
|
|
69
|
+
:param source_file: A binary file opened for reading that contains PNG image data.
|
|
70
|
+
:returns: A tuple of the image's width and height in pixels.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_read_signature(source_file)
|
|
74
|
+
|
|
75
|
+
# validate IHDR (Image Header) chunk
|
|
76
|
+
ihdr = _read_chunk(source_file)
|
|
77
|
+
if ihdr is None:
|
|
78
|
+
raise ValueError("missing IHDR chunk")
|
|
79
|
+
|
|
80
|
+
if ihdr.length != 13:
|
|
81
|
+
raise ValueError("invalid chunk length")
|
|
82
|
+
if ihdr.name != b"IHDR":
|
|
83
|
+
raise ValueError(f"expected: IHDR chunk; got: {ihdr.name!r}")
|
|
84
|
+
|
|
85
|
+
(
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
bit_depth, # pyright: ignore[reportUnusedVariable]
|
|
89
|
+
color_type, # pyright: ignore[reportUnusedVariable]
|
|
90
|
+
compression, # pyright: ignore[reportUnusedVariable]
|
|
91
|
+
filter, # pyright: ignore[reportUnusedVariable]
|
|
92
|
+
interlace, # pyright: ignore[reportUnusedVariable]
|
|
93
|
+
) = unpack(">IIBBBBB", ihdr.data) # spellchecker:disable-line
|
|
94
|
+
return width, height
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@overload
|
|
98
|
+
def extract_png_dimensions(*, data: bytes) -> tuple[int, int]: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@overload
|
|
102
|
+
def extract_png_dimensions(*, path: str | Path) -> tuple[int, int]: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_png_dimensions(*, data: bytes | None = None, path: str | Path | None = None) -> tuple[int, int]:
|
|
106
|
+
"""
|
|
107
|
+
Returns the width and height of a PNG image inspecting its header.
|
|
108
|
+
|
|
109
|
+
:param data: PNG image data.
|
|
110
|
+
:param path: Path to the PNG image file.
|
|
111
|
+
:returns: A tuple of the image's width and height in pixels.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
if data is not None and path is not None:
|
|
115
|
+
raise TypeError("expected: either `data` or `path`; got: both")
|
|
116
|
+
elif data is not None:
|
|
117
|
+
with BytesIO(data) as f:
|
|
118
|
+
return _extract_png_dimensions(f)
|
|
119
|
+
elif path is not None:
|
|
120
|
+
with open(path, "rb") as f:
|
|
121
|
+
return _extract_png_dimensions(f)
|
|
122
|
+
else:
|
|
123
|
+
raise TypeError("expected: either `data` or `path`; got: neither")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write_chunk(f: BinaryIO, chunk: _Chunk) -> None:
|
|
127
|
+
f.write(chunk.length.to_bytes(4, "big"))
|
|
128
|
+
f.write(chunk.name)
|
|
129
|
+
f.write(chunk.data)
|
|
130
|
+
f.write(chunk.crc)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _remove_png_chunks(names: Iterable[str], source_file: BinaryIO, target_file: BinaryIO) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Rewrites a PNG file by removing chunks with the specified names.
|
|
136
|
+
|
|
137
|
+
:param source_file: A binary file opened for reading that contains PNG image data.
|
|
138
|
+
:param target_file: A binary file opened for writing to receive PNG image data.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
exclude_set = set(name.encode("ascii") for name in names)
|
|
142
|
+
|
|
143
|
+
_read_signature(source_file)
|
|
144
|
+
target_file.write(b"\x89PNG\r\n\x1a\n")
|
|
145
|
+
|
|
146
|
+
while True:
|
|
147
|
+
chunk = _read_chunk(source_file)
|
|
148
|
+
if chunk is None:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if chunk.name not in exclude_set:
|
|
152
|
+
_write_chunk(target_file, chunk)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@overload
|
|
156
|
+
def remove_png_chunks(names: Iterable[str], *, source_data: bytes) -> bytes: ...
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@overload
|
|
160
|
+
def remove_png_chunks(names: Iterable[str], *, source_path: str | Path) -> bytes: ...
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@overload
|
|
164
|
+
def remove_png_chunks(names: Iterable[str], *, source_data: bytes, target_path: str | Path) -> None: ...
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@overload
|
|
168
|
+
def remove_png_chunks(names: Iterable[str], *, source_path: str | Path, target_path: str | Path) -> None: ...
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remove_png_chunks(
|
|
172
|
+
names: Iterable[str], *, source_data: bytes | None = None, source_path: str | Path | None = None, target_path: str | Path | None = None
|
|
173
|
+
) -> bytes | None:
|
|
174
|
+
"""
|
|
175
|
+
Rewrites a PNG file by removing chunks with the specified names.
|
|
176
|
+
|
|
177
|
+
:param source_data: PNG image data.
|
|
178
|
+
:param source_path: Path to the file to read from.
|
|
179
|
+
:param target_path: Path to the file to write to.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
if source_data is not None and source_path is not None:
|
|
183
|
+
raise TypeError("expected: either `source_data` or `source_path`; got: both")
|
|
184
|
+
elif source_data is not None:
|
|
185
|
+
|
|
186
|
+
def source_reader() -> BinaryIO:
|
|
187
|
+
return BytesIO(source_data)
|
|
188
|
+
elif source_path is not None:
|
|
189
|
+
|
|
190
|
+
def source_reader() -> BinaryIO:
|
|
191
|
+
return open(source_path, "rb")
|
|
192
|
+
else:
|
|
193
|
+
raise TypeError("expected: either `source_data` or `source_path`; got: neither")
|
|
194
|
+
|
|
195
|
+
if target_path is None:
|
|
196
|
+
with source_reader() as source_file, BytesIO() as memory_file:
|
|
197
|
+
_remove_png_chunks(names, source_file, memory_file)
|
|
198
|
+
return memory_file.getvalue()
|
|
199
|
+
else:
|
|
200
|
+
with source_reader() as source_file, open(target_path, "wb") as target_file:
|
|
201
|
+
_remove_png_chunks(names, source_file, target_file)
|
|
202
|
+
return None
|
md2conf/processor.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -15,16 +15,18 @@ from typing import Iterable
|
|
|
15
15
|
|
|
16
16
|
from .collection import ConfluencePageCollection
|
|
17
17
|
from .converter import ConfluenceDocument
|
|
18
|
-
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
19
18
|
from .environment import ArgumentError
|
|
20
19
|
from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
|
|
21
20
|
from .metadata import ConfluenceSiteMetadata
|
|
21
|
+
from .options import ConfluencePageID, DocumentOptions
|
|
22
22
|
from .scanner import Scanner
|
|
23
23
|
|
|
24
24
|
LOGGER = logging.getLogger(__name__)
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class DocumentNode:
|
|
28
|
+
"Represents a Markdown document in a hierarchy."
|
|
29
|
+
|
|
28
30
|
absolute_path: Path
|
|
29
31
|
page_id: str | None
|
|
30
32
|
space_key: str | None
|
|
@@ -49,24 +51,42 @@ class DocumentNode:
|
|
|
49
51
|
self._children = []
|
|
50
52
|
|
|
51
53
|
def count(self) -> int:
|
|
54
|
+
"Number of descendants in the sub-tree spanned by this node (excluding the top-level node)."
|
|
55
|
+
|
|
52
56
|
c = len(self._children)
|
|
53
57
|
for child in self._children:
|
|
54
58
|
c += child.count()
|
|
55
59
|
return c
|
|
56
60
|
|
|
57
61
|
def add_child(self, child: "DocumentNode") -> None:
|
|
62
|
+
"Adds a new node to the list of direct children."
|
|
63
|
+
|
|
58
64
|
self._children.append(child)
|
|
59
65
|
|
|
60
66
|
def children(self) -> Iterable["DocumentNode"]:
|
|
67
|
+
"Direct children of this node."
|
|
68
|
+
|
|
61
69
|
for child in self._children:
|
|
62
70
|
yield child
|
|
63
71
|
|
|
64
72
|
def descendants(self) -> Iterable["DocumentNode"]:
|
|
73
|
+
"""
|
|
74
|
+
Descendants of this node, part of its sub-tree.
|
|
75
|
+
|
|
76
|
+
Traversal follows depth-first search.
|
|
77
|
+
"""
|
|
78
|
+
|
|
65
79
|
for child in self._children:
|
|
66
80
|
yield child
|
|
67
81
|
yield from child.descendants()
|
|
68
82
|
|
|
69
83
|
def all(self) -> Iterable["DocumentNode"]:
|
|
84
|
+
"""
|
|
85
|
+
Descendants of this node, part of the sub-tree including the top-level node.
|
|
86
|
+
|
|
87
|
+
Traversal follows depth-first search.
|
|
88
|
+
"""
|
|
89
|
+
|
|
70
90
|
yield self
|
|
71
91
|
for child in self._children:
|
|
72
92
|
yield from child.all()
|
|
@@ -77,7 +97,7 @@ class Processor:
|
|
|
77
97
|
Processes a single Markdown page or a directory of Markdown pages.
|
|
78
98
|
"""
|
|
79
99
|
|
|
80
|
-
options:
|
|
100
|
+
options: DocumentOptions
|
|
81
101
|
site: ConfluenceSiteMetadata
|
|
82
102
|
root_dir: Path
|
|
83
103
|
|
|
@@ -85,7 +105,7 @@ class Processor:
|
|
|
85
105
|
|
|
86
106
|
def __init__(
|
|
87
107
|
self,
|
|
88
|
-
options:
|
|
108
|
+
options: DocumentOptions,
|
|
89
109
|
site: ConfluenceSiteMetadata,
|
|
90
110
|
root_dir: Path,
|
|
91
111
|
) -> None:
|
|
@@ -140,7 +160,7 @@ class Processor:
|
|
|
140
160
|
self._update_page(page_id, document, path)
|
|
141
161
|
|
|
142
162
|
@abstractmethod
|
|
143
|
-
def _synchronize_tree(self,
|
|
163
|
+
def _synchronize_tree(self, tree: DocumentNode, root_id: ConfluencePageID | None) -> None:
|
|
144
164
|
"""
|
|
145
165
|
Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
|
|
146
166
|
|
|
@@ -228,12 +248,13 @@ class Processor:
|
|
|
228
248
|
# extract information from a Markdown document found in a local directory.
|
|
229
249
|
document = Scanner().read(path)
|
|
230
250
|
|
|
251
|
+
props = document.properties
|
|
231
252
|
return DocumentNode(
|
|
232
253
|
absolute_path=path,
|
|
233
|
-
page_id=
|
|
234
|
-
space_key=
|
|
235
|
-
title=
|
|
236
|
-
synchronized=
|
|
254
|
+
page_id=props.page_id,
|
|
255
|
+
space_key=props.space_key,
|
|
256
|
+
title=props.title,
|
|
257
|
+
synchronized=props.synchronized if props.synchronized is not None else True,
|
|
237
258
|
)
|
|
238
259
|
|
|
239
260
|
def _generate_hash(self, absolute_path: Path) -> str:
|
|
@@ -247,10 +268,10 @@ class Processor:
|
|
|
247
268
|
|
|
248
269
|
|
|
249
270
|
class ProcessorFactory:
|
|
250
|
-
options:
|
|
271
|
+
options: DocumentOptions
|
|
251
272
|
site: ConfluenceSiteMetadata
|
|
252
273
|
|
|
253
|
-
def __init__(self, options:
|
|
274
|
+
def __init__(self, options: DocumentOptions, site: ConfluenceSiteMetadata) -> None:
|
|
254
275
|
self.options = options
|
|
255
276
|
self.site = site
|
|
256
277
|
|
md2conf/publisher.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -10,12 +10,13 @@ import logging
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
|
|
13
|
-
from .
|
|
13
|
+
from .attachment import attachment_name
|
|
14
|
+
from .compatibility import override, path_relative_to
|
|
15
|
+
from .converter import ConfluenceDocument, get_volatile_attributes, get_volatile_elements
|
|
14
16
|
from .csf import AC_ATTR, elements_from_string
|
|
15
|
-
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
16
17
|
from .environment import PageError
|
|
17
|
-
from .extra import override, path_relative_to
|
|
18
18
|
from .metadata import ConfluencePageMetadata
|
|
19
|
+
from .options import ConfluencePageID, DocumentOptions
|
|
19
20
|
from .processor import Converter, DocumentNode, Processor, ProcessorFactory
|
|
20
21
|
from .xml import is_xml_equal, unwrap_substitute
|
|
21
22
|
|
|
@@ -29,7 +30,7 @@ class SynchronizingProcessor(Processor):
|
|
|
29
30
|
|
|
30
31
|
api: ConfluenceSession
|
|
31
32
|
|
|
32
|
-
def __init__(self, api: ConfluenceSession, options:
|
|
33
|
+
def __init__(self, api: ConfluenceSession, options: DocumentOptions, root_dir: Path) -> None:
|
|
33
34
|
"""
|
|
34
35
|
Initializes a new processor instance.
|
|
35
36
|
|
|
@@ -42,7 +43,7 @@ class SynchronizingProcessor(Processor):
|
|
|
42
43
|
self.api = api
|
|
43
44
|
|
|
44
45
|
@override
|
|
45
|
-
def _synchronize_tree(self,
|
|
46
|
+
def _synchronize_tree(self, tree: DocumentNode, root_id: ConfluencePageID | None) -> None:
|
|
46
47
|
"""
|
|
47
48
|
Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
|
|
48
49
|
|
|
@@ -51,21 +52,16 @@ class SynchronizingProcessor(Processor):
|
|
|
51
52
|
Updates the original Markdown document to add tags to associate the document with its corresponding Confluence page.
|
|
52
53
|
"""
|
|
53
54
|
|
|
54
|
-
if
|
|
55
|
-
raise PageError(f"expected: root page ID in options, or explicit page ID in {
|
|
56
|
-
elif
|
|
57
|
-
|
|
58
|
-
raise PageError(f"mismatched inferred page ID of {root_id.page_id} and explicit page ID in {root.absolute_path}")
|
|
59
|
-
|
|
60
|
-
real_id = root_id
|
|
55
|
+
if tree.page_id is None and root_id is None:
|
|
56
|
+
raise PageError(f"expected: root page ID in options, or explicit page ID in {tree.absolute_path}")
|
|
57
|
+
elif tree.page_id is not None:
|
|
58
|
+
real_id = ConfluencePageID(tree.page_id) # explicit page ID takes precedence
|
|
61
59
|
elif root_id is not None:
|
|
62
60
|
real_id = root_id
|
|
63
|
-
elif root.page_id is not None:
|
|
64
|
-
real_id = ConfluencePageID(root.page_id)
|
|
65
61
|
else:
|
|
66
62
|
raise NotImplementedError("condition not exhaustive")
|
|
67
63
|
|
|
68
|
-
self._synchronize_subtree(
|
|
64
|
+
self._synchronize_subtree(tree, real_id)
|
|
69
65
|
|
|
70
66
|
def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID) -> None:
|
|
71
67
|
if node.page_id is not None:
|
|
@@ -212,7 +208,7 @@ class SynchronizingProcessor(Processor):
|
|
|
212
208
|
class SynchronizingProcessorFactory(ProcessorFactory):
|
|
213
209
|
api: ConfluenceSession
|
|
214
210
|
|
|
215
|
-
def __init__(self, api: ConfluenceSession, options:
|
|
211
|
+
def __init__(self, api: ConfluenceSession, options: DocumentOptions) -> None:
|
|
216
212
|
super().__init__(options, api.site)
|
|
217
213
|
self.api = api
|
|
218
214
|
|
|
@@ -227,5 +223,5 @@ class Publisher(Converter):
|
|
|
227
223
|
This is the class instantiated by the command-line application.
|
|
228
224
|
"""
|
|
229
225
|
|
|
230
|
-
def __init__(self, api: ConfluenceSession, options:
|
|
226
|
+
def __init__(self, api: ConfluenceSession, options: DocumentOptions) -> None:
|
|
231
227
|
super().__init__(SynchronizingProcessorFactory(api, options))
|
md2conf/scanner.py
CHANGED
|
@@ -1,55 +1,34 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import re
|
|
10
|
-
import typing
|
|
11
9
|
from dataclasses import dataclass
|
|
12
10
|
from pathlib import Path
|
|
13
|
-
from typing import
|
|
11
|
+
from typing import TypeVar
|
|
14
12
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
from .
|
|
13
|
+
from .coalesce import coalesce
|
|
14
|
+
from .frontmatter import extract_frontmatter_json, extract_value
|
|
15
|
+
from .options import LayoutOptions
|
|
18
16
|
from .serializer import JsonType, json_to_object
|
|
19
17
|
|
|
20
18
|
T = TypeVar("T")
|
|
21
19
|
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
values.append(match.group(1))
|
|
28
|
-
return ""
|
|
29
|
-
|
|
30
|
-
text = re.sub(pattern, _repl_func, text, count=1, flags=re.ASCII)
|
|
31
|
-
value = values[0] if values else None
|
|
32
|
-
return value, text
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def extract_frontmatter_block(text: str) -> tuple[str | None, str]:
|
|
36
|
-
"Extracts the front-matter from a Markdown document as a blob of unparsed text."
|
|
37
|
-
|
|
38
|
-
return extract_value(r"(?ms)\A---$(.+?)^---$", text)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def extract_frontmatter_properties(text: str) -> tuple[dict[str, JsonType] | None, str]:
|
|
42
|
-
"Extracts the front-matter from a Markdown document as a dictionary."
|
|
43
|
-
|
|
44
|
-
block, text = extract_frontmatter_block(text)
|
|
21
|
+
@dataclass
|
|
22
|
+
class AliasProperties:
|
|
23
|
+
"""
|
|
24
|
+
An object that holds properties extracted from the front-matter of a Markdown document.
|
|
45
25
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if isinstance(data, dict):
|
|
50
|
-
properties = typing.cast(dict[str, JsonType], data)
|
|
26
|
+
:param confluence_page_id: Confluence page ID. (Alternative name for JSON de-serialization.)
|
|
27
|
+
:param confluence_space_key: Confluence space key. (Alternative name for JSON de-serialization.)
|
|
28
|
+
"""
|
|
51
29
|
|
|
52
|
-
|
|
30
|
+
confluence_page_id: str | None = None
|
|
31
|
+
confluence_space_key: str | None = None
|
|
53
32
|
|
|
54
33
|
|
|
55
34
|
@dataclass
|
|
@@ -59,26 +38,22 @@ class DocumentProperties:
|
|
|
59
38
|
|
|
60
39
|
:param page_id: Confluence page ID.
|
|
61
40
|
:param space_key: Confluence space key.
|
|
62
|
-
:param confluence_page_id: Confluence page ID. (Alternative name for JSON de-serialization.)
|
|
63
|
-
:param confluence_space_key: Confluence space key. (Alternative name for JSON de-serialization.)
|
|
64
41
|
:param generated_by: Text identifying the tool that generated the document.
|
|
65
42
|
:param title: The title extracted from front-matter.
|
|
66
43
|
:param tags: A list of tags (content labels) extracted from front-matter.
|
|
67
44
|
:param synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
68
45
|
:param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
|
|
69
|
-
:param
|
|
46
|
+
:param layout: Layout options for content on a Confluence page.
|
|
70
47
|
"""
|
|
71
48
|
|
|
72
49
|
page_id: str | None = None
|
|
73
50
|
space_key: str | None = None
|
|
74
|
-
confluence_page_id: str | None = None
|
|
75
|
-
confluence_space_key: str | None = None
|
|
76
51
|
generated_by: str | None = None
|
|
77
52
|
title: str | None = None
|
|
78
53
|
tags: list[str] | None = None
|
|
79
54
|
synchronized: bool | None = None
|
|
80
55
|
properties: dict[str, JsonType] | None = None
|
|
81
|
-
|
|
56
|
+
layout: LayoutOptions | None = None
|
|
82
57
|
|
|
83
58
|
|
|
84
59
|
@dataclass
|
|
@@ -86,25 +61,11 @@ class ScannedDocument:
|
|
|
86
61
|
"""
|
|
87
62
|
An object that holds properties extracted from a Markdown document, including remaining source text.
|
|
88
63
|
|
|
89
|
-
:param
|
|
90
|
-
:param space_key: Confluence space key.
|
|
91
|
-
:param generated_by: Text identifying the tool that generated the document.
|
|
92
|
-
:param title: The title extracted from front-matter.
|
|
93
|
-
:param tags: A list of tags (content labels) extracted from front-matter.
|
|
94
|
-
:param synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
95
|
-
:param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
|
|
96
|
-
:param alignment: Alignment for block-level images and formulas.
|
|
64
|
+
:param properties: Properties extracted from the front-matter of a Markdown document.
|
|
97
65
|
:param text: Text that remains after front-matter and inline properties have been extracted.
|
|
98
66
|
"""
|
|
99
67
|
|
|
100
|
-
|
|
101
|
-
space_key: str | None
|
|
102
|
-
generated_by: str | None
|
|
103
|
-
title: str | None
|
|
104
|
-
tags: list[str] | None
|
|
105
|
-
synchronized: bool | None
|
|
106
|
-
properties: dict[str, JsonType] | None
|
|
107
|
-
alignment: Literal["center", "left", "right"] | None
|
|
68
|
+
properties: DocumentProperties
|
|
108
69
|
text: str
|
|
109
70
|
|
|
110
71
|
|
|
@@ -127,77 +88,19 @@ class Scanner:
|
|
|
127
88
|
# extract 'generated-by' tag text
|
|
128
89
|
generated_by, text = extract_value(r"<!--\s+generated[-_]by:\s*(.*)\s+-->", text)
|
|
129
90
|
|
|
130
|
-
|
|
131
|
-
tags: list[str] | None = None
|
|
132
|
-
synchronized: bool | None = None
|
|
133
|
-
properties: dict[str, JsonType] | None = None
|
|
134
|
-
alignment: Literal["center", "left", "right"] | None = None
|
|
91
|
+
body_props = DocumentProperties(page_id=page_id, space_key=space_key, generated_by=generated_by)
|
|
135
92
|
|
|
136
93
|
# extract front-matter
|
|
137
|
-
data, text =
|
|
94
|
+
data, text = extract_frontmatter_json(text)
|
|
138
95
|
if data is not None:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return ScannedDocument(
|
|
150
|
-
page_id=page_id,
|
|
151
|
-
space_key=space_key,
|
|
152
|
-
generated_by=generated_by,
|
|
153
|
-
title=title,
|
|
154
|
-
tags=tags,
|
|
155
|
-
synchronized=synchronized,
|
|
156
|
-
properties=properties,
|
|
157
|
-
alignment=alignment,
|
|
158
|
-
text=text,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
@dataclass
|
|
163
|
-
class MermaidProperties:
|
|
164
|
-
"""
|
|
165
|
-
An object that holds the front-matter properties structure for Mermaid diagrams.
|
|
166
|
-
|
|
167
|
-
:param title: The title of the diagram.
|
|
168
|
-
:param config: Configuration options for rendering.
|
|
169
|
-
"""
|
|
170
|
-
|
|
171
|
-
title: str | None = None
|
|
172
|
-
config: MermaidConfigProperties | None = None
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class MermaidScanner:
|
|
176
|
-
"""
|
|
177
|
-
Extracts properties from the JSON/YAML front-matter of a Mermaid diagram.
|
|
178
|
-
"""
|
|
179
|
-
|
|
180
|
-
def read(self, content: str) -> MermaidProperties:
|
|
181
|
-
"""
|
|
182
|
-
Extracts rendering preferences from a Mermaid front-matter content.
|
|
183
|
-
|
|
184
|
-
```
|
|
185
|
-
---
|
|
186
|
-
title: Tiny flow diagram
|
|
187
|
-
config:
|
|
188
|
-
scale: 1
|
|
189
|
-
---
|
|
190
|
-
flowchart LR
|
|
191
|
-
A[Component A] --> B[Component B]
|
|
192
|
-
B --> C[Component C]
|
|
193
|
-
```
|
|
194
|
-
"""
|
|
195
|
-
|
|
196
|
-
properties, _ = extract_frontmatter_properties(content)
|
|
197
|
-
if properties is not None:
|
|
198
|
-
front_matter = json_to_object(MermaidProperties, properties)
|
|
199
|
-
config = front_matter.config or MermaidConfigProperties()
|
|
200
|
-
|
|
201
|
-
return MermaidProperties(title=front_matter.title, config=config)
|
|
202
|
-
|
|
203
|
-
return MermaidProperties()
|
|
96
|
+
frontmatter_props = json_to_object(DocumentProperties, data)
|
|
97
|
+
alias_props = json_to_object(AliasProperties, data)
|
|
98
|
+
if alias_props.confluence_page_id is not None:
|
|
99
|
+
frontmatter_props.page_id = alias_props.confluence_page_id
|
|
100
|
+
if alias_props.confluence_space_key is not None:
|
|
101
|
+
frontmatter_props.space_key = alias_props.confluence_space_key
|
|
102
|
+
props = coalesce(body_props, frontmatter_props)
|
|
103
|
+
else:
|
|
104
|
+
props = body_props
|
|
105
|
+
|
|
106
|
+
return ScannedDocument(properties=props, text=text)
|
md2conf/serializer.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from typing import TypeVar
|
|
12
12
|
|
|
13
|
-
from cattrs.preconf.orjson import make_converter
|
|
13
|
+
from cattrs.preconf.orjson import make_converter # spellchecker:disable-line
|
|
14
14
|
|
|
15
15
|
JsonType = None | bool | int | float | str | dict[str, "JsonType"] | list["JsonType"]
|
|
16
16
|
JsonComposite = dict[str, "JsonType"] | list["JsonType"]
|
md2conf/svg.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -317,3 +317,25 @@ def _parse_viewbox(viewbox: str) -> tuple[int | None, int | None]:
|
|
|
317
317
|
return width, height
|
|
318
318
|
except ValueError:
|
|
319
319
|
return None, None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def fix_svg_get_dimensions(image_data: bytes) -> tuple[bytes, int | None, int | None]:
|
|
323
|
+
"""
|
|
324
|
+
Post-processes SVG diagram data by fixing dimensions and extracting metadata.
|
|
325
|
+
|
|
326
|
+
This handles the common pattern for SVG diagrams:
|
|
327
|
+
|
|
328
|
+
1. fixes SVG dimensions (converts percentage-based to explicit pixels), and
|
|
329
|
+
2. extracts width/height from the SVG.
|
|
330
|
+
|
|
331
|
+
:param image_data: Raw SVG data as bytes.
|
|
332
|
+
:returns: Tuple of update raw data, image width, image height.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
# fix SVG to have explicit width/height instead of percentages
|
|
336
|
+
image_data = fix_svg_dimensions(image_data)
|
|
337
|
+
|
|
338
|
+
# extract dimensions from the fixed SVG
|
|
339
|
+
width, height = get_svg_dimensions_from_bytes(image_data)
|
|
340
|
+
|
|
341
|
+
return image_data, width, height
|