markdown-to-confluence 0.3.2__py3-none-any.whl → 0.3.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.3.2.dist-info → markdown_to_confluence-0.3.4.dist-info}/METADATA +32 -8
- markdown_to_confluence-0.3.4.dist-info/RECORD +22 -0
- {markdown_to_confluence-0.3.2.dist-info → markdown_to_confluence-0.3.4.dist-info}/WHEEL +1 -1
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +40 -14
- md2conf/api.py +135 -50
- md2conf/application.py +89 -160
- md2conf/converter.py +142 -44
- md2conf/emoji.py +3 -1
- md2conf/local.py +132 -0
- md2conf/mermaid.py +1 -1
- md2conf/metadata.py +42 -0
- md2conf/processor.py +159 -88
- md2conf/properties.py +40 -16
- markdown_to_confluence-0.3.2.dist-info/RECORD +0 -20
- {markdown_to_confluence-0.3.2.dist-info → markdown_to_confluence-0.3.4.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.3.2.dist-info → markdown_to_confluence-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.3.2.dist-info → markdown_to_confluence-0.3.4.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.3.2.dist-info → markdown_to_confluence-0.3.4.dist-info}/zip-safe +0 -0
md2conf/application.py
CHANGED
|
@@ -6,8 +6,8 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import hashlib
|
|
9
10
|
import logging
|
|
10
|
-
import os.path
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional
|
|
13
13
|
|
|
@@ -15,155 +15,43 @@ from .api import ConfluencePage, ConfluenceSession
|
|
|
15
15
|
from .converter import (
|
|
16
16
|
ConfluenceDocument,
|
|
17
17
|
ConfluenceDocumentOptions,
|
|
18
|
-
|
|
19
|
-
ConfluenceQualifiedID,
|
|
18
|
+
ConfluencePageID,
|
|
20
19
|
attachment_name,
|
|
21
20
|
extract_frontmatter_title,
|
|
22
21
|
extract_qualified_id,
|
|
23
|
-
read_qualified_id,
|
|
24
22
|
)
|
|
25
|
-
from .
|
|
23
|
+
from .metadata import ConfluencePageMetadata
|
|
24
|
+
from .processor import Converter, Processor, ProcessorFactory
|
|
25
|
+
from .properties import PageError
|
|
26
26
|
|
|
27
27
|
LOGGER = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
class
|
|
31
|
-
"
|
|
30
|
+
class SynchronizingProcessor(Processor):
|
|
31
|
+
"""
|
|
32
|
+
Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
|
|
33
|
+
"""
|
|
32
34
|
|
|
33
35
|
api: ConfluenceSession
|
|
34
|
-
options: ConfluenceDocumentOptions
|
|
35
36
|
|
|
36
37
|
def __init__(
|
|
37
|
-
self, api: ConfluenceSession, options: ConfluenceDocumentOptions
|
|
38
|
-
) -> None:
|
|
39
|
-
self.api = api
|
|
40
|
-
self.options = options
|
|
41
|
-
|
|
42
|
-
def synchronize(self, path: Path) -> None:
|
|
43
|
-
"Synchronizes a single Markdown page or a directory of Markdown pages."
|
|
44
|
-
|
|
45
|
-
path = path.resolve(True)
|
|
46
|
-
if path.is_dir():
|
|
47
|
-
self.synchronize_directory(path)
|
|
48
|
-
elif path.is_file():
|
|
49
|
-
self.synchronize_page(path)
|
|
50
|
-
else:
|
|
51
|
-
raise ValueError(f"expected: valid file or directory path; got: {path}")
|
|
52
|
-
|
|
53
|
-
def synchronize_page(
|
|
54
|
-
self, page_path: Path, root_dir: Optional[Path] = None
|
|
55
|
-
) -> None:
|
|
56
|
-
"Synchronizes a single Markdown page with Confluence."
|
|
57
|
-
|
|
58
|
-
page_path = page_path.resolve(True)
|
|
59
|
-
if root_dir is None:
|
|
60
|
-
root_dir = page_path.parent
|
|
61
|
-
else:
|
|
62
|
-
root_dir = root_dir.resolve(True)
|
|
63
|
-
|
|
64
|
-
self._synchronize_page(page_path, root_dir, {})
|
|
65
|
-
|
|
66
|
-
def synchronize_directory(
|
|
67
|
-
self, local_dir: Path, root_dir: Optional[Path] = None
|
|
68
|
-
) -> None:
|
|
69
|
-
"Synchronizes a directory of Markdown pages with Confluence."
|
|
70
|
-
|
|
71
|
-
local_dir = local_dir.resolve(True)
|
|
72
|
-
if root_dir is None:
|
|
73
|
-
root_dir = local_dir
|
|
74
|
-
else:
|
|
75
|
-
root_dir = root_dir.resolve(True)
|
|
76
|
-
|
|
77
|
-
LOGGER.info("Synchronizing directory: %s", local_dir)
|
|
78
|
-
|
|
79
|
-
# Step 1: build index of all page metadata
|
|
80
|
-
page_metadata: dict[Path, ConfluencePageMetadata] = {}
|
|
81
|
-
root_id = (
|
|
82
|
-
ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
|
|
83
|
-
if self.options.root_page_id
|
|
84
|
-
else None
|
|
85
|
-
)
|
|
86
|
-
self._index_directory(local_dir, root_id, page_metadata)
|
|
87
|
-
LOGGER.info("Indexed %d page(s)", len(page_metadata))
|
|
88
|
-
|
|
89
|
-
# Step 2: convert each page
|
|
90
|
-
for page_path in page_metadata.keys():
|
|
91
|
-
self._synchronize_page(page_path, root_dir, page_metadata)
|
|
92
|
-
|
|
93
|
-
def _synchronize_page(
|
|
94
|
-
self,
|
|
95
|
-
page_path: Path,
|
|
96
|
-
root_dir: Path,
|
|
97
|
-
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
38
|
+
self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path
|
|
98
39
|
) -> None:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
LOGGER.info("Synchronizing page: %s", page_path)
|
|
102
|
-
document = ConfluenceDocument(page_path, self.options, root_dir, page_metadata)
|
|
103
|
-
self._update_document(document, base_path)
|
|
104
|
-
|
|
105
|
-
def _index_directory(
|
|
106
|
-
self,
|
|
107
|
-
local_dir: Path,
|
|
108
|
-
root_id: Optional[ConfluenceQualifiedID],
|
|
109
|
-
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
110
|
-
) -> None:
|
|
111
|
-
"Indexes Markdown files in a directory recursively."
|
|
112
|
-
|
|
113
|
-
LOGGER.info("Indexing directory: %s", local_dir)
|
|
114
|
-
|
|
115
|
-
matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
|
|
116
|
-
|
|
117
|
-
files: list[Path] = []
|
|
118
|
-
directories: list[Path] = []
|
|
119
|
-
for entry in os.scandir(local_dir):
|
|
120
|
-
if matcher.is_excluded(entry.name, entry.is_dir()):
|
|
121
|
-
continue
|
|
122
|
-
|
|
123
|
-
if entry.is_file():
|
|
124
|
-
files.append(Path(local_dir) / entry.name)
|
|
125
|
-
elif entry.is_dir():
|
|
126
|
-
directories.append(Path(local_dir) / entry.name)
|
|
127
|
-
|
|
128
|
-
# make page act as parent node in Confluence
|
|
129
|
-
parent_doc: Optional[Path] = None
|
|
130
|
-
if (Path(local_dir) / "index.md") in files:
|
|
131
|
-
parent_doc = Path(local_dir) / "index.md"
|
|
132
|
-
elif (Path(local_dir) / "README.md") in files:
|
|
133
|
-
parent_doc = Path(local_dir) / "README.md"
|
|
134
|
-
elif (Path(local_dir) / f"{local_dir.name}.md") in files:
|
|
135
|
-
parent_doc = Path(local_dir) / f"{local_dir.name}.md"
|
|
136
|
-
|
|
137
|
-
if parent_doc is None and self.options.keep_hierarchy:
|
|
138
|
-
parent_doc = Path(local_dir) / "index.md"
|
|
139
|
-
|
|
140
|
-
# create a blank page in Confluence for the directory entry
|
|
141
|
-
with open(parent_doc, "w"):
|
|
142
|
-
pass
|
|
143
|
-
|
|
144
|
-
if parent_doc is not None:
|
|
145
|
-
files.remove(parent_doc)
|
|
146
|
-
|
|
147
|
-
metadata = self._get_or_create_page(parent_doc, root_id)
|
|
148
|
-
LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
|
|
149
|
-
page_metadata[parent_doc] = metadata
|
|
150
|
-
|
|
151
|
-
parent_id = read_qualified_id(parent_doc) or root_id
|
|
152
|
-
else:
|
|
153
|
-
parent_id = root_id
|
|
40
|
+
"""
|
|
41
|
+
Initializes a new processor instance.
|
|
154
42
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
43
|
+
:param api: Holds information about an open session to a Confluence server.
|
|
44
|
+
:param options: Options that control the generated page content.
|
|
45
|
+
:param root_dir: File system directory that acts as topmost root node.
|
|
46
|
+
"""
|
|
159
47
|
|
|
160
|
-
|
|
161
|
-
|
|
48
|
+
super().__init__(options, api.site, root_dir)
|
|
49
|
+
self.api = api
|
|
162
50
|
|
|
163
51
|
def _get_or_create_page(
|
|
164
52
|
self,
|
|
165
53
|
absolute_path: Path,
|
|
166
|
-
parent_id: Optional[
|
|
54
|
+
parent_id: Optional[ConfluencePageID],
|
|
167
55
|
*,
|
|
168
56
|
title: Optional[str] = None,
|
|
169
57
|
) -> ConfluencePageMetadata:
|
|
@@ -173,54 +61,60 @@ class Application:
|
|
|
173
61
|
|
|
174
62
|
# parse file
|
|
175
63
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
176
|
-
|
|
64
|
+
text = f.read()
|
|
177
65
|
|
|
178
|
-
qualified_id,
|
|
179
|
-
frontmatter_title, _ = extract_frontmatter_title(document)
|
|
66
|
+
qualified_id, text = extract_qualified_id(text)
|
|
180
67
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
68
|
+
overwrite = False
|
|
69
|
+
if qualified_id is None:
|
|
70
|
+
# create new Confluence page
|
|
184
71
|
if parent_id is None:
|
|
185
|
-
raise
|
|
72
|
+
raise PageError(
|
|
186
73
|
f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
|
|
187
74
|
)
|
|
188
75
|
|
|
189
|
-
# assign title from
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
76
|
+
# assign title from front-matter if present
|
|
77
|
+
if title is None:
|
|
78
|
+
title, _ = extract_frontmatter_title(text)
|
|
79
|
+
|
|
80
|
+
# use file name (without extension) and path hash if no title is supplied
|
|
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}]"
|
|
87
|
+
|
|
88
|
+
confluence_page = self._create_page(absolute_path, text, title, parent_id)
|
|
89
|
+
else:
|
|
90
|
+
# look up existing Confluence page
|
|
91
|
+
confluence_page = self.api.get_page(qualified_id.page_id)
|
|
193
92
|
|
|
194
93
|
space_key = (
|
|
195
94
|
self.api.space_id_to_key(confluence_page.space_id)
|
|
196
95
|
if confluence_page.space_id
|
|
197
|
-
else self.
|
|
96
|
+
else self.site.space_key
|
|
198
97
|
)
|
|
199
98
|
|
|
200
99
|
return ConfluencePageMetadata(
|
|
201
|
-
domain=self.api.domain,
|
|
202
|
-
base_path=self.api.base_path,
|
|
203
100
|
page_id=confluence_page.id,
|
|
204
101
|
space_key=space_key,
|
|
205
|
-
title=confluence_page.title
|
|
102
|
+
title=confluence_page.title,
|
|
103
|
+
overwrite=overwrite,
|
|
206
104
|
)
|
|
207
105
|
|
|
208
106
|
def _create_page(
|
|
209
107
|
self,
|
|
210
108
|
absolute_path: Path,
|
|
211
109
|
document: str,
|
|
212
|
-
title:
|
|
213
|
-
parent_id:
|
|
110
|
+
title: str,
|
|
111
|
+
parent_id: ConfluencePageID,
|
|
214
112
|
) -> ConfluencePage:
|
|
215
|
-
"
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if title is None:
|
|
219
|
-
title = absolute_path.stem
|
|
113
|
+
"""
|
|
114
|
+
Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
|
|
115
|
+
"""
|
|
220
116
|
|
|
221
|
-
confluence_page = self.api.get_or_create_page(
|
|
222
|
-
title, parent_id.page_id, space_key=parent_id.space_key
|
|
223
|
-
)
|
|
117
|
+
confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
|
|
224
118
|
self._update_markdown(
|
|
225
119
|
absolute_path,
|
|
226
120
|
document,
|
|
@@ -229,9 +123,14 @@ class Application:
|
|
|
229
123
|
)
|
|
230
124
|
return confluence_page
|
|
231
125
|
|
|
232
|
-
def
|
|
233
|
-
"
|
|
126
|
+
def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Saves a new version of a Confluence document.
|
|
129
|
+
|
|
130
|
+
Invokes Confluence REST API to persist the new version.
|
|
131
|
+
"""
|
|
234
132
|
|
|
133
|
+
base_path = path.parent
|
|
235
134
|
for image in document.images:
|
|
236
135
|
self.api.upload_attachment(
|
|
237
136
|
document.id.page_id,
|
|
@@ -247,8 +146,12 @@ class Application:
|
|
|
247
146
|
)
|
|
248
147
|
|
|
249
148
|
content = document.xhtml()
|
|
149
|
+
|
|
150
|
+
# leave title as it is for existing pages, update title for pages with randomly assigned title
|
|
151
|
+
title = document.title if self.page_metadata[path].overwrite else None
|
|
152
|
+
|
|
250
153
|
LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
|
|
251
|
-
self.api.update_page(document.id.page_id, content, title=
|
|
154
|
+
self.api.update_page(document.id.page_id, content, title=title)
|
|
252
155
|
|
|
253
156
|
def _update_markdown(
|
|
254
157
|
self,
|
|
@@ -257,7 +160,9 @@ class Application:
|
|
|
257
160
|
page_id: str,
|
|
258
161
|
space_key: Optional[str],
|
|
259
162
|
) -> None:
|
|
260
|
-
"
|
|
163
|
+
"""
|
|
164
|
+
Writes the Confluence page ID and space key at the beginning of the Markdown file.
|
|
165
|
+
"""
|
|
261
166
|
|
|
262
167
|
content: list[str] = []
|
|
263
168
|
|
|
@@ -277,3 +182,27 @@ class Application:
|
|
|
277
182
|
|
|
278
183
|
with open(path, "w", encoding="utf-8") as file:
|
|
279
184
|
file.write("\n".join(content))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SynchronizingProcessorFactory(ProcessorFactory):
|
|
188
|
+
api: ConfluenceSession
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self, api: ConfluenceSession, options: ConfluenceDocumentOptions
|
|
192
|
+
) -> None:
|
|
193
|
+
super().__init__(options, api.site)
|
|
194
|
+
self.api = api
|
|
195
|
+
|
|
196
|
+
def create(self, root_dir: Path) -> Processor:
|
|
197
|
+
return SynchronizingProcessor(self.api, self.options, root_dir)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Application(Converter):
|
|
201
|
+
"""
|
|
202
|
+
The entry point for Markdown to Confluence conversion.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self, api: ConfluenceSession, options: ConfluenceDocumentOptions
|
|
207
|
+
) -> None:
|
|
208
|
+
super().__init__(SynchronizingProcessorFactory(api, options))
|