markdown-to-confluence 0.3.4__py3-none-any.whl → 0.4.0__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.4.dist-info → markdown_to_confluence-0.4.0.dist-info}/METADATA +131 -14
- markdown_to_confluence-0.4.0.dist-info/RECORD +25 -0
- {markdown_to_confluence-0.3.4.dist-info → markdown_to_confluence-0.4.0.dist-info}/WHEEL +1 -1
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +18 -7
- md2conf/api.py +492 -187
- md2conf/application.py +100 -83
- md2conf/collection.py +31 -0
- md2conf/converter.py +51 -112
- md2conf/emoji.py +28 -3
- md2conf/extra.py +14 -0
- md2conf/local.py +33 -45
- md2conf/matcher.py +54 -13
- md2conf/mermaid.py +10 -4
- md2conf/metadata.py +1 -3
- md2conf/processor.py +137 -43
- md2conf/properties.py +24 -5
- md2conf/scanner.py +149 -0
- markdown_to_confluence-0.3.4.dist-info/RECORD +0 -22
- {markdown_to_confluence-0.3.4.dist-info → markdown_to_confluence-0.4.0.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.3.4.dist-info → markdown_to_confluence-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.3.4.dist-info → markdown_to_confluence-0.4.0.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.3.4.dist-info → markdown_to_confluence-0.4.0.dist-info}/zip-safe +0 -0
md2conf/application.py
CHANGED
|
@@ -6,22 +6,20 @@ 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
|
from pathlib import Path
|
|
12
11
|
from typing import Optional
|
|
13
12
|
|
|
14
|
-
from .api import
|
|
13
|
+
from .api import ConfluenceLabel, ConfluenceSession
|
|
15
14
|
from .converter import (
|
|
16
15
|
ConfluenceDocument,
|
|
17
16
|
ConfluenceDocumentOptions,
|
|
18
17
|
ConfluencePageID,
|
|
19
18
|
attachment_name,
|
|
20
|
-
extract_frontmatter_title,
|
|
21
|
-
extract_qualified_id,
|
|
22
19
|
)
|
|
20
|
+
from .extra import override
|
|
23
21
|
from .metadata import ConfluencePageMetadata
|
|
24
|
-
from .processor import Converter, Processor, ProcessorFactory
|
|
22
|
+
from .processor import Converter, DocumentNode, Processor, ProcessorFactory
|
|
25
23
|
from .properties import PageError
|
|
26
24
|
|
|
27
25
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -48,82 +46,78 @@ class SynchronizingProcessor(Processor):
|
|
|
48
46
|
super().__init__(options, api.site, root_dir)
|
|
49
47
|
self.api = api
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*,
|
|
56
|
-
title: Optional[str] = None,
|
|
57
|
-
) -> ConfluencePageMetadata:
|
|
58
|
-
"""
|
|
59
|
-
Creates a new Confluence page if no page is linked in the Markdown document.
|
|
49
|
+
@override
|
|
50
|
+
def _synchronize_tree(
|
|
51
|
+
self, root: DocumentNode, root_id: Optional[ConfluencePageID]
|
|
52
|
+
) -> None:
|
|
60
53
|
"""
|
|
54
|
+
Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
|
|
61
55
|
|
|
62
|
-
|
|
63
|
-
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
64
|
-
text = f.read()
|
|
56
|
+
Creates new Confluence pages as necessary, e.g. if no page is linked in the Markdown document, or no page is found with lookup by page title.
|
|
65
57
|
|
|
66
|
-
|
|
58
|
+
Updates the original Markdown document to add tags to associate the document with its corresponding Confluence page.
|
|
59
|
+
"""
|
|
67
60
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
if root.page_id is None and root_id is None:
|
|
62
|
+
raise PageError(
|
|
63
|
+
f"expected: root page ID in options, or explicit page ID in {root.absolute_path}"
|
|
64
|
+
)
|
|
65
|
+
elif root.page_id is not None and root_id is not None:
|
|
66
|
+
if root.page_id != root_id.page_id:
|
|
72
67
|
raise PageError(
|
|
73
|
-
f"
|
|
68
|
+
f"mismatched inferred page ID of {root_id.page_id} and explicit page ID in {root.absolute_path}"
|
|
74
69
|
)
|
|
75
70
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
real_id = root_id
|
|
72
|
+
elif root_id is not None:
|
|
73
|
+
real_id = root_id
|
|
74
|
+
elif root.page_id is not None:
|
|
75
|
+
real_id = ConfluencePageID(root.page_id)
|
|
76
|
+
else:
|
|
77
|
+
raise NotImplementedError("condition not exhaustive")
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
if title is None:
|
|
82
|
-
overwrite = True
|
|
83
|
-
relative_path = absolute_path.relative_to(self.root_dir)
|
|
84
|
-
hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
|
|
85
|
-
digest = "".join(f"{c:x}" for c in hash.digest())
|
|
86
|
-
title = f"{absolute_path.stem} [{digest}]"
|
|
79
|
+
self._synchronize_subtree(root, real_id)
|
|
87
80
|
|
|
88
|
-
|
|
81
|
+
def _synchronize_subtree(
|
|
82
|
+
self, node: DocumentNode, parent_id: ConfluencePageID
|
|
83
|
+
) -> None:
|
|
84
|
+
if node.page_id is not None:
|
|
85
|
+
# verify if page exists
|
|
86
|
+
page = self.api.get_page_properties(node.page_id)
|
|
87
|
+
update = False
|
|
88
|
+
elif node.title is not None:
|
|
89
|
+
# look up page by title
|
|
90
|
+
page = self.api.get_or_create_page(node.title, parent_id.page_id)
|
|
91
|
+
update = True
|
|
89
92
|
else:
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
# always create a new page
|
|
94
|
+
digest = self._generate_hash(node.absolute_path)
|
|
95
|
+
title = f"{node.absolute_path.stem} [{digest}]"
|
|
96
|
+
page = self.api.create_page(parent_id.page_id, title, "")
|
|
97
|
+
update = True
|
|
98
|
+
|
|
99
|
+
space_key = self.api.space_id_to_key(page.spaceId)
|
|
100
|
+
if update:
|
|
101
|
+
self._update_markdown(
|
|
102
|
+
node.absolute_path,
|
|
103
|
+
page_id=page.id,
|
|
104
|
+
space_key=space_key,
|
|
105
|
+
)
|
|
98
106
|
|
|
99
|
-
|
|
100
|
-
page_id=
|
|
107
|
+
data = ConfluencePageMetadata(
|
|
108
|
+
page_id=page.id,
|
|
101
109
|
space_key=space_key,
|
|
102
|
-
title=
|
|
103
|
-
overwrite=overwrite,
|
|
110
|
+
title=page.title,
|
|
104
111
|
)
|
|
112
|
+
self.page_metadata.add(node.absolute_path, data)
|
|
105
113
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
absolute_path: Path,
|
|
109
|
-
document: str,
|
|
110
|
-
title: str,
|
|
111
|
-
parent_id: ConfluencePageID,
|
|
112
|
-
) -> ConfluencePage:
|
|
113
|
-
"""
|
|
114
|
-
Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
|
|
115
|
-
"""
|
|
116
|
-
|
|
117
|
-
confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
|
|
118
|
-
self._update_markdown(
|
|
119
|
-
absolute_path,
|
|
120
|
-
document,
|
|
121
|
-
confluence_page.id,
|
|
122
|
-
self.api.space_id_to_key(confluence_page.space_id),
|
|
123
|
-
)
|
|
124
|
-
return confluence_page
|
|
114
|
+
for child_node in node.children():
|
|
115
|
+
self._synchronize_subtree(child_node, ConfluencePageID(page.id))
|
|
125
116
|
|
|
126
|
-
|
|
117
|
+
@override
|
|
118
|
+
def _update_page(
|
|
119
|
+
self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
|
|
120
|
+
) -> None:
|
|
127
121
|
"""
|
|
128
122
|
Saves a new version of a Confluence document.
|
|
129
123
|
|
|
@@ -133,37 +127,60 @@ class SynchronizingProcessor(Processor):
|
|
|
133
127
|
base_path = path.parent
|
|
134
128
|
for image in document.images:
|
|
135
129
|
self.api.upload_attachment(
|
|
136
|
-
|
|
130
|
+
page_id.page_id,
|
|
137
131
|
attachment_name(image),
|
|
138
132
|
attachment_path=base_path / image,
|
|
139
133
|
)
|
|
140
134
|
|
|
141
135
|
for name, data in document.embedded_images.items():
|
|
142
136
|
self.api.upload_attachment(
|
|
143
|
-
|
|
137
|
+
page_id.page_id,
|
|
144
138
|
name,
|
|
145
139
|
raw_data=data,
|
|
146
140
|
)
|
|
147
141
|
|
|
148
142
|
content = document.xhtml()
|
|
143
|
+
LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
|
|
149
144
|
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
title = None
|
|
146
|
+
if document.title is not None:
|
|
147
|
+
meta = self.page_metadata.get(path)
|
|
148
|
+
if (
|
|
149
|
+
meta is not None
|
|
150
|
+
and meta.space_key is not None
|
|
151
|
+
and meta.title != document.title
|
|
152
|
+
):
|
|
153
|
+
conflicting_page_id = self.api.page_exists(
|
|
154
|
+
document.title, space_id=self.api.space_key_to_id(meta.space_key)
|
|
155
|
+
)
|
|
156
|
+
if conflicting_page_id is None:
|
|
157
|
+
title = document.title
|
|
158
|
+
else:
|
|
159
|
+
LOGGER.info(
|
|
160
|
+
"Document title of %s conflicts with Confluence page title of %s",
|
|
161
|
+
path,
|
|
162
|
+
conflicting_page_id,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.api.update_page(page_id.page_id, content, title=title)
|
|
166
|
+
|
|
167
|
+
if document.labels is not None:
|
|
168
|
+
self.api.update_labels(
|
|
169
|
+
page_id.page_id,
|
|
170
|
+
[
|
|
171
|
+
ConfluenceLabel(name=label, prefix="global")
|
|
172
|
+
for label in document.labels
|
|
173
|
+
],
|
|
174
|
+
)
|
|
152
175
|
|
|
153
|
-
|
|
154
|
-
self.api.update_page(document.id.page_id, content, title=title)
|
|
155
|
-
|
|
156
|
-
def _update_markdown(
|
|
157
|
-
self,
|
|
158
|
-
path: Path,
|
|
159
|
-
document: str,
|
|
160
|
-
page_id: str,
|
|
161
|
-
space_key: Optional[str],
|
|
162
|
-
) -> None:
|
|
176
|
+
def _update_markdown(self, path: Path, *, page_id: str, space_key: str) -> None:
|
|
163
177
|
"""
|
|
164
178
|
Writes the Confluence page ID and space key at the beginning of the Markdown file.
|
|
165
179
|
"""
|
|
166
180
|
|
|
181
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
182
|
+
document = file.read()
|
|
183
|
+
|
|
167
184
|
content: list[str] = []
|
|
168
185
|
|
|
169
186
|
# check if the file has frontmatter
|
|
@@ -175,9 +192,7 @@ class SynchronizingProcessor(Processor):
|
|
|
175
192
|
content.append(document[:index])
|
|
176
193
|
|
|
177
194
|
content.append(f"<!-- confluence-page-id: {page_id} -->")
|
|
178
|
-
|
|
179
|
-
content.append(f"<!-- confluence-space-key: {space_key} -->")
|
|
180
|
-
|
|
195
|
+
content.append(f"<!-- confluence-space-key: {space_key} -->")
|
|
181
196
|
content.append(document[index:])
|
|
182
197
|
|
|
183
198
|
with open(path, "w", encoding="utf-8") as file:
|
|
@@ -200,6 +215,8 @@ class SynchronizingProcessorFactory(ProcessorFactory):
|
|
|
200
215
|
class Application(Converter):
|
|
201
216
|
"""
|
|
202
217
|
The entry point for Markdown to Confluence conversion.
|
|
218
|
+
|
|
219
|
+
This is the class instantiated by the command-line application.
|
|
203
220
|
"""
|
|
204
221
|
|
|
205
222
|
def __init__(
|
md2conf/collection.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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 pathlib import Path
|
|
10
|
+
from typing import Iterable, Optional
|
|
11
|
+
|
|
12
|
+
from .metadata import ConfluencePageMetadata
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfluencePageCollection:
|
|
16
|
+
_metadata: dict[Path, ConfluencePageMetadata]
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._metadata = {}
|
|
20
|
+
|
|
21
|
+
def __len__(self) -> int:
|
|
22
|
+
return len(self._metadata)
|
|
23
|
+
|
|
24
|
+
def add(self, path: Path, data: ConfluencePageMetadata) -> None:
|
|
25
|
+
self._metadata[path] = data
|
|
26
|
+
|
|
27
|
+
def get(self, path: Path) -> Optional[ConfluencePageMetadata]:
|
|
28
|
+
return self._metadata.get(path)
|
|
29
|
+
|
|
30
|
+
def items(self) -> Iterable[tuple[Path, ConfluencePageMetadata]]:
|
|
31
|
+
return self._metadata.items()
|
md2conf/converter.py
CHANGED
|
@@ -18,16 +18,17 @@ import xml.etree.ElementTree
|
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Any, Literal, Optional, Union
|
|
21
|
-
from urllib.parse import ParseResult, urlparse, urlunparse
|
|
21
|
+
from urllib.parse import ParseResult, quote_plus, urlparse, urlunparse
|
|
22
22
|
|
|
23
23
|
import lxml.etree as ET
|
|
24
24
|
import markdown
|
|
25
|
-
import yaml
|
|
26
25
|
from lxml.builder import ElementMaker
|
|
27
26
|
|
|
27
|
+
from .collection import ConfluencePageCollection
|
|
28
28
|
from .mermaid import render_diagram
|
|
29
|
-
from .metadata import
|
|
29
|
+
from .metadata import ConfluenceSiteMetadata
|
|
30
30
|
from .properties import PageError
|
|
31
|
+
from .scanner import ScannedDocument, Scanner
|
|
31
32
|
|
|
32
33
|
namespaces = {
|
|
33
34
|
"ac": "http://atlassian.com/content",
|
|
@@ -66,6 +67,19 @@ def is_relative_url(url: str) -> bool:
|
|
|
66
67
|
return not bool(urlparts.scheme) and not bool(urlparts.netloc)
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
def encode_title(text: str) -> str:
|
|
71
|
+
"Converts a title string such that it is safe to embed into a Confluence URL."
|
|
72
|
+
|
|
73
|
+
# replace unsafe characters with space
|
|
74
|
+
text = re.sub(r"[^A-Za-z0-9._~()'!*:@,;+?-]+", " ", text)
|
|
75
|
+
|
|
76
|
+
# replace multiple consecutive spaces with single space
|
|
77
|
+
text = re.sub(r"\s\s+", " ", text)
|
|
78
|
+
|
|
79
|
+
# URL-encode
|
|
80
|
+
return quote_plus(text.strip())
|
|
81
|
+
|
|
82
|
+
|
|
69
83
|
def emoji_generator(
|
|
70
84
|
index: str,
|
|
71
85
|
shortname: str,
|
|
@@ -78,8 +92,10 @@ def emoji_generator(
|
|
|
78
92
|
md: markdown.Markdown,
|
|
79
93
|
) -> xml.etree.ElementTree.Element:
|
|
80
94
|
name = (alias or shortname).strip(":")
|
|
81
|
-
span = xml.etree.ElementTree.Element("span", {"data-emoji": name})
|
|
95
|
+
span = xml.etree.ElementTree.Element("span", {"data-emoji-shortname": name})
|
|
82
96
|
if uc is not None:
|
|
97
|
+
span.attrib["data-emoji-unicode"] = uc
|
|
98
|
+
|
|
83
99
|
# convert series of Unicode code point hexadecimal values into characters
|
|
84
100
|
span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
|
|
85
101
|
else:
|
|
@@ -349,7 +365,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
349
365
|
images: list[Path]
|
|
350
366
|
embedded_images: dict[str, bytes]
|
|
351
367
|
site_metadata: ConfluenceSiteMetadata
|
|
352
|
-
page_metadata:
|
|
368
|
+
page_metadata: ConfluencePageCollection
|
|
353
369
|
|
|
354
370
|
def __init__(
|
|
355
371
|
self,
|
|
@@ -357,7 +373,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
357
373
|
path: Path,
|
|
358
374
|
root_dir: Path,
|
|
359
375
|
site_metadata: ConfluenceSiteMetadata,
|
|
360
|
-
page_metadata:
|
|
376
|
+
page_metadata: ConfluencePageCollection,
|
|
361
377
|
) -> None:
|
|
362
378
|
super().__init__()
|
|
363
379
|
self.options = options
|
|
@@ -466,7 +482,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
466
482
|
"Confluence space key required for building full web URLs"
|
|
467
483
|
)
|
|
468
484
|
|
|
469
|
-
page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{link_metadata.title}"
|
|
485
|
+
page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{encode_title(link_metadata.title)}"
|
|
470
486
|
|
|
471
487
|
components = ParseResult(
|
|
472
488
|
scheme="https",
|
|
@@ -821,7 +837,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
821
837
|
)
|
|
822
838
|
|
|
823
839
|
def _transform_emoji(self, elem: ET._Element) -> ET._Element:
|
|
824
|
-
shortname = elem.attrib.get("data-emoji", "")
|
|
840
|
+
shortname = elem.attrib.get("data-emoji-shortname", "")
|
|
841
|
+
unicode = elem.attrib.get("data-emoji-unicode", None)
|
|
825
842
|
alt = elem.text or ""
|
|
826
843
|
|
|
827
844
|
# <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="😉"/>
|
|
@@ -831,8 +848,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
831
848
|
"emoticon",
|
|
832
849
|
{
|
|
833
850
|
# use "blue-star" as a placeholder name to ensure wiki page loads in timely manner
|
|
834
|
-
ET.QName(namespaces["ac"], "name"):
|
|
851
|
+
ET.QName(namespaces["ac"], "name"): shortname,
|
|
835
852
|
ET.QName(namespaces["ac"], "emoji-shortname"): f":{shortname}:",
|
|
853
|
+
ET.QName(namespaces["ac"], "emoji-id"): unicode,
|
|
836
854
|
ET.QName(namespaces["ac"], "emoji-fallback"): alt,
|
|
837
855
|
},
|
|
838
856
|
)
|
|
@@ -930,7 +948,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
930
948
|
elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
|
|
931
949
|
return self._transform_block(child[0])
|
|
932
950
|
|
|
933
|
-
elif child.tag == "span" and child.attrib.has_key("data-emoji"):
|
|
951
|
+
elif child.tag == "span" and child.attrib.has_key("data-emoji-shortname"):
|
|
934
952
|
return self._transform_emoji(child)
|
|
935
953
|
|
|
936
954
|
return None
|
|
@@ -949,78 +967,15 @@ class DocumentError(RuntimeError):
|
|
|
949
967
|
"Raised when a converted Markdown document has an unexpected element or attribute."
|
|
950
968
|
|
|
951
969
|
|
|
952
|
-
def extract_value(pattern: str, text: str) -> tuple[Optional[str], str]:
|
|
953
|
-
values: list[str] = []
|
|
954
|
-
|
|
955
|
-
def _repl_func(matchobj: re.Match) -> str:
|
|
956
|
-
values.append(matchobj.group(1))
|
|
957
|
-
return ""
|
|
958
|
-
|
|
959
|
-
text = re.sub(pattern, _repl_func, text, 1, re.ASCII)
|
|
960
|
-
value = values[0] if values else None
|
|
961
|
-
return value, text
|
|
962
|
-
|
|
963
|
-
|
|
964
970
|
@dataclass
|
|
965
971
|
class ConfluencePageID:
|
|
966
972
|
page_id: str
|
|
967
973
|
|
|
968
|
-
def __init__(self, page_id: str):
|
|
969
|
-
self.page_id = page_id
|
|
970
|
-
|
|
971
974
|
|
|
972
975
|
@dataclass
|
|
973
976
|
class ConfluenceQualifiedID:
|
|
974
977
|
page_id: str
|
|
975
|
-
space_key:
|
|
976
|
-
|
|
977
|
-
def __init__(self, page_id: str, space_key: Optional[str] = None):
|
|
978
|
-
self.page_id = page_id
|
|
979
|
-
self.space_key = space_key
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
def extract_qualified_id(text: str) -> tuple[Optional[ConfluenceQualifiedID], str]:
|
|
983
|
-
"Extracts the Confluence page ID and space key from a Markdown document."
|
|
984
|
-
|
|
985
|
-
page_id, text = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", text)
|
|
986
|
-
|
|
987
|
-
if page_id is None:
|
|
988
|
-
return None, text
|
|
989
|
-
|
|
990
|
-
# extract Confluence space key
|
|
991
|
-
space_key, text = extract_value(r"<!--\s+confluence-space-key:\s*(\S+)\s+-->", text)
|
|
992
|
-
|
|
993
|
-
return ConfluenceQualifiedID(page_id, space_key), text
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
def extract_frontmatter(text: str) -> tuple[Optional[str], str]:
|
|
997
|
-
"Extracts the front matter from a Markdown document."
|
|
998
|
-
|
|
999
|
-
return extract_value(r"(?ms)\A---$(.+?)^---$", text)
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
def extract_frontmatter_title(text: str) -> tuple[Optional[str], str]:
|
|
1003
|
-
frontmatter, text = extract_frontmatter(text)
|
|
1004
|
-
|
|
1005
|
-
title: Optional[str] = None
|
|
1006
|
-
if frontmatter is not None:
|
|
1007
|
-
properties = yaml.safe_load(frontmatter)
|
|
1008
|
-
if isinstance(properties, dict):
|
|
1009
|
-
property_title = properties.get("title")
|
|
1010
|
-
if isinstance(property_title, str):
|
|
1011
|
-
title = property_title
|
|
1012
|
-
|
|
1013
|
-
return title, text
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
def read_qualified_id(absolute_path: Path) -> Optional[ConfluenceQualifiedID]:
|
|
1017
|
-
"Reads the Confluence page ID and space key from a Markdown document."
|
|
1018
|
-
|
|
1019
|
-
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
1020
|
-
document = f.read()
|
|
1021
|
-
|
|
1022
|
-
qualified_id, _ = extract_qualified_id(document)
|
|
1023
|
-
return qualified_id
|
|
978
|
+
space_key: str
|
|
1024
979
|
|
|
1025
980
|
|
|
1026
981
|
@dataclass
|
|
@@ -1055,8 +1010,8 @@ class ConversionError(RuntimeError):
|
|
|
1055
1010
|
|
|
1056
1011
|
|
|
1057
1012
|
class ConfluenceDocument:
|
|
1058
|
-
id: ConfluenceQualifiedID
|
|
1059
1013
|
title: Optional[str]
|
|
1014
|
+
labels: Optional[list[str]]
|
|
1060
1015
|
links: list[str]
|
|
1061
1016
|
images: list[Path]
|
|
1062
1017
|
|
|
@@ -1070,64 +1025,48 @@ class ConfluenceDocument:
|
|
|
1070
1025
|
options: ConfluenceDocumentOptions,
|
|
1071
1026
|
root_dir: Path,
|
|
1072
1027
|
site_metadata: ConfluenceSiteMetadata,
|
|
1073
|
-
page_metadata:
|
|
1074
|
-
) -> "ConfluenceDocument":
|
|
1028
|
+
page_metadata: ConfluencePageCollection,
|
|
1029
|
+
) -> tuple[ConfluencePageID, "ConfluenceDocument"]:
|
|
1075
1030
|
path = path.resolve(True)
|
|
1076
1031
|
|
|
1077
|
-
|
|
1078
|
-
text = f.read()
|
|
1032
|
+
document = Scanner().read(path)
|
|
1079
1033
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1034
|
+
if document.page_id is not None:
|
|
1035
|
+
page_id = ConfluencePageID(document.page_id)
|
|
1036
|
+
else:
|
|
1083
1037
|
# look up Confluence page ID in metadata
|
|
1084
1038
|
metadata = page_metadata.get(path)
|
|
1085
1039
|
if metadata is not None:
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
)
|
|
1089
|
-
if qualified_id is None:
|
|
1090
|
-
raise PageError("missing Confluence page ID")
|
|
1040
|
+
page_id = ConfluencePageID(metadata.page_id)
|
|
1041
|
+
else:
|
|
1042
|
+
raise PageError("missing Confluence page ID")
|
|
1091
1043
|
|
|
1092
|
-
return ConfluenceDocument(
|
|
1093
|
-
path,
|
|
1044
|
+
return page_id, ConfluenceDocument(
|
|
1045
|
+
path, document, options, root_dir, site_metadata, page_metadata
|
|
1094
1046
|
)
|
|
1095
1047
|
|
|
1096
1048
|
def __init__(
|
|
1097
1049
|
self,
|
|
1098
1050
|
path: Path,
|
|
1099
|
-
|
|
1100
|
-
qualified_id: ConfluenceQualifiedID,
|
|
1051
|
+
document: ScannedDocument,
|
|
1101
1052
|
options: ConfluenceDocumentOptions,
|
|
1102
1053
|
root_dir: Path,
|
|
1103
1054
|
site_metadata: ConfluenceSiteMetadata,
|
|
1104
|
-
page_metadata:
|
|
1055
|
+
page_metadata: ConfluencePageCollection,
|
|
1105
1056
|
) -> None:
|
|
1106
1057
|
self.options = options
|
|
1107
|
-
self.id = qualified_id
|
|
1108
|
-
|
|
1109
|
-
# extract frontmatter
|
|
1110
|
-
self.title, text = extract_frontmatter_title(text)
|
|
1111
|
-
|
|
1112
|
-
# extract 'generated-by' tag text
|
|
1113
|
-
generated_by_tag, text = extract_value(
|
|
1114
|
-
r"<!--\s+generated-by:\s*(.*)\s+-->", text
|
|
1115
|
-
)
|
|
1116
1058
|
|
|
1117
1059
|
# convert to HTML
|
|
1118
|
-
html = markdown_to_html(text)
|
|
1060
|
+
html = markdown_to_html(document.text)
|
|
1119
1061
|
|
|
1120
1062
|
# parse Markdown document
|
|
1121
1063
|
if self.options.generated_by is not None:
|
|
1122
|
-
|
|
1123
|
-
generated_by_text = generated_by_tag
|
|
1124
|
-
else:
|
|
1125
|
-
generated_by_text = self.options.generated_by
|
|
1064
|
+
generated_by = document.generated_by or self.options.generated_by
|
|
1126
1065
|
else:
|
|
1127
|
-
|
|
1066
|
+
generated_by = None
|
|
1128
1067
|
|
|
1129
|
-
if
|
|
1130
|
-
generated_by_html = markdown_to_html(
|
|
1068
|
+
if generated_by is not None:
|
|
1069
|
+
generated_by_html = markdown_to_html(generated_by)
|
|
1131
1070
|
|
|
1132
1071
|
content = [
|
|
1133
1072
|
'<ac:structured-macro ac:name="info" ac:schema-version="1">',
|
|
@@ -1161,8 +1100,8 @@ class ConfluenceDocument:
|
|
|
1161
1100
|
self.images = converter.images
|
|
1162
1101
|
self.embedded_images = converter.embedded_images
|
|
1163
1102
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1103
|
+
self.title = document.title or converter.toc.get_title()
|
|
1104
|
+
self.labels = document.tags
|
|
1166
1105
|
|
|
1167
1106
|
def xhtml(self) -> str:
|
|
1168
1107
|
return elements_to_string(self.root)
|
|
@@ -1214,7 +1153,7 @@ def _content_to_string(dtd_path: Path, content: str) -> str:
|
|
|
1214
1153
|
|
|
1215
1154
|
data = [
|
|
1216
1155
|
'<?xml version="1.0"?>',
|
|
1217
|
-
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path}">'
|
|
1156
|
+
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}">'
|
|
1218
1157
|
f"<root{ns_attr_list}>",
|
|
1219
1158
|
]
|
|
1220
1159
|
data.append(content)
|
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,14 @@
|
|
|
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
|