markdown-to-confluence 0.2.6__py3-none-any.whl → 0.3.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.2.6.dist-info → markdown_to_confluence-0.3.0.dist-info}/METADATA +45 -8
- markdown_to_confluence-0.3.0.dist-info/RECORD +21 -0
- {markdown_to_confluence-0.2.6.dist-info → markdown_to_confluence-0.3.0.dist-info}/WHEEL +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +4 -4
- md2conf/api.py +190 -126
- md2conf/application.py +22 -32
- md2conf/converter.py +95 -46
- md2conf/emoji.py +1 -1
- md2conf/matcher.py +11 -6
- md2conf/mermaid.py +1 -1
- md2conf/processor.py +7 -7
- md2conf/properties.py +4 -4
- md2conf/util.py +1 -1
- markdown_to_confluence-0.2.6.dist-info/RECORD +0 -21
- {markdown_to_confluence-0.2.6.dist-info → markdown_to_confluence-0.3.0.dist-info}/LICENSE +0 -0
- {markdown_to_confluence-0.2.6.dist-info → markdown_to_confluence-0.3.0.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.2.6.dist-info → markdown_to_confluence-0.3.0.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.2.6.dist-info → markdown_to_confluence-0.3.0.dist-info}/zip-safe +0 -0
md2conf/api.py
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2025, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import enum
|
|
9
10
|
import io
|
|
10
11
|
import json
|
|
11
12
|
import logging
|
|
12
13
|
import mimetypes
|
|
13
14
|
import typing
|
|
14
|
-
from contextlib import contextmanager
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from types import TracebackType
|
|
18
|
-
from typing import
|
|
18
|
+
from typing import Optional, Union
|
|
19
19
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
20
20
|
|
|
21
21
|
import requests
|
|
@@ -31,12 +31,17 @@ JsonType = Union[
|
|
|
31
31
|
int,
|
|
32
32
|
float,
|
|
33
33
|
str,
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
dict[str, "JsonType"],
|
|
35
|
+
list["JsonType"],
|
|
36
36
|
]
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
class ConfluenceVersion(enum.Enum):
|
|
40
|
+
VERSION_1 = "rest/api"
|
|
41
|
+
VERSION_2 = "api/v2"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
|
|
40
45
|
"Builds a URL with scheme, host, port, path and query string parameters."
|
|
41
46
|
|
|
42
47
|
scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
|
|
@@ -66,7 +71,7 @@ class ConfluenceAttachment:
|
|
|
66
71
|
@dataclass
|
|
67
72
|
class ConfluencePage:
|
|
68
73
|
id: str
|
|
69
|
-
|
|
74
|
+
space_id: str
|
|
70
75
|
title: str
|
|
71
76
|
version: int
|
|
72
77
|
content: str
|
|
@@ -101,7 +106,7 @@ class ConfluenceAPI:
|
|
|
101
106
|
|
|
102
107
|
def __exit__(
|
|
103
108
|
self,
|
|
104
|
-
exc_type: Optional[
|
|
109
|
+
exc_type: Optional[type[BaseException]],
|
|
105
110
|
exc_val: Optional[BaseException],
|
|
106
111
|
exc_tb: Optional[TracebackType],
|
|
107
112
|
) -> None:
|
|
@@ -116,6 +121,9 @@ class ConfluenceSession:
|
|
|
116
121
|
base_path: str
|
|
117
122
|
space_key: str
|
|
118
123
|
|
|
124
|
+
_space_id_to_key: dict[str, str]
|
|
125
|
+
_space_key_to_id: dict[str, str]
|
|
126
|
+
|
|
119
127
|
def __init__(
|
|
120
128
|
self, session: requests.Session, domain: str, base_path: str, space_key: str
|
|
121
129
|
) -> None:
|
|
@@ -124,30 +132,46 @@ class ConfluenceSession:
|
|
|
124
132
|
self.base_path = base_path
|
|
125
133
|
self.space_key = space_key
|
|
126
134
|
|
|
135
|
+
self._space_id_to_key = {}
|
|
136
|
+
self._space_key_to_id = {}
|
|
137
|
+
|
|
127
138
|
def close(self) -> None:
|
|
128
139
|
self.session.close()
|
|
140
|
+
self.session = requests.Session()
|
|
129
141
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
def _build_url(
|
|
143
|
+
self,
|
|
144
|
+
version: ConfluenceVersion,
|
|
145
|
+
path: str,
|
|
146
|
+
query: Optional[dict[str, str]] = None,
|
|
147
|
+
) -> str:
|
|
148
|
+
"""
|
|
149
|
+
Builds a full URL for invoking the Confluence API.
|
|
138
150
|
|
|
139
|
-
|
|
140
|
-
|
|
151
|
+
:param prefix: A URL path prefix that depends on the Confluence API version.
|
|
152
|
+
:param path: Path of API endpoint to invoke.
|
|
153
|
+
:param query: Query parameters to pass to the API endpoint.
|
|
154
|
+
:returns: A full URL.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
base_url = f"https://{self.domain}{self.base_path}{version.value}{path}"
|
|
141
158
|
return build_url(base_url, query)
|
|
142
159
|
|
|
143
|
-
def _invoke(
|
|
144
|
-
|
|
160
|
+
def _invoke(
|
|
161
|
+
self,
|
|
162
|
+
version: ConfluenceVersion,
|
|
163
|
+
path: str,
|
|
164
|
+
query: Optional[dict[str, str]] = None,
|
|
165
|
+
) -> JsonType:
|
|
166
|
+
"Execute an HTTP request via Confluence API."
|
|
167
|
+
|
|
168
|
+
url = self._build_url(version, path, query)
|
|
145
169
|
response = self.session.get(url)
|
|
146
170
|
response.raise_for_status()
|
|
147
171
|
return response.json()
|
|
148
172
|
|
|
149
|
-
def _save(self, path: str, data: dict) -> None:
|
|
150
|
-
url = self._build_url(path)
|
|
173
|
+
def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
|
|
174
|
+
url = self._build_url(version, path)
|
|
151
175
|
response = self.session.put(
|
|
152
176
|
url,
|
|
153
177
|
data=json.dumps(data),
|
|
@@ -155,24 +179,68 @@ class ConfluenceSession:
|
|
|
155
179
|
)
|
|
156
180
|
response.raise_for_status()
|
|
157
181
|
|
|
182
|
+
def space_id_to_key(self, id: str) -> str:
|
|
183
|
+
"Finds the Confluence space key for a space ID."
|
|
184
|
+
|
|
185
|
+
key = self._space_id_to_key.get(id)
|
|
186
|
+
if key is None:
|
|
187
|
+
payload = self._invoke(
|
|
188
|
+
ConfluenceVersion.VERSION_2,
|
|
189
|
+
"/spaces",
|
|
190
|
+
{"ids": id, "type": "global", "status": "current"},
|
|
191
|
+
)
|
|
192
|
+
payload = typing.cast(dict[str, JsonType], payload)
|
|
193
|
+
results = typing.cast(list[JsonType], payload["results"])
|
|
194
|
+
if len(results) != 1:
|
|
195
|
+
raise ConfluenceError(f"unique space not found with id: {id}")
|
|
196
|
+
|
|
197
|
+
result = typing.cast(dict[str, JsonType], results[0])
|
|
198
|
+
key = typing.cast(str, result["key"])
|
|
199
|
+
|
|
200
|
+
self._space_id_to_key[id] = key
|
|
201
|
+
|
|
202
|
+
return key
|
|
203
|
+
|
|
204
|
+
def space_key_to_id(self, key: str) -> str:
|
|
205
|
+
"Finds the Confluence space ID for a space key."
|
|
206
|
+
|
|
207
|
+
id = self._space_key_to_id.get(key)
|
|
208
|
+
if id is None:
|
|
209
|
+
payload = self._invoke(
|
|
210
|
+
ConfluenceVersion.VERSION_2,
|
|
211
|
+
"/spaces",
|
|
212
|
+
{"keys": key, "type": "global", "status": "current"},
|
|
213
|
+
)
|
|
214
|
+
payload = typing.cast(dict[str, JsonType], payload)
|
|
215
|
+
results = typing.cast(list[JsonType], payload["results"])
|
|
216
|
+
if len(results) != 1:
|
|
217
|
+
raise ConfluenceError(f"unique space not found with key: {key}")
|
|
218
|
+
|
|
219
|
+
result = typing.cast(dict[str, JsonType], results[0])
|
|
220
|
+
id = typing.cast(str, result["id"])
|
|
221
|
+
|
|
222
|
+
self._space_key_to_id[key] = id
|
|
223
|
+
|
|
224
|
+
return id
|
|
225
|
+
|
|
158
226
|
def get_attachment_by_name(
|
|
159
|
-
self, page_id: str, filename: str
|
|
227
|
+
self, page_id: str, filename: str
|
|
160
228
|
) -> ConfluenceAttachment:
|
|
161
|
-
path = f"/
|
|
162
|
-
query = {"
|
|
163
|
-
data = typing.cast(
|
|
229
|
+
path = f"/pages/{page_id}/attachments"
|
|
230
|
+
query = {"filename": filename}
|
|
231
|
+
data = typing.cast(
|
|
232
|
+
dict[str, JsonType], self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
233
|
+
)
|
|
164
234
|
|
|
165
|
-
results = typing.cast(
|
|
235
|
+
results = typing.cast(list[JsonType], data["results"])
|
|
166
236
|
if len(results) != 1:
|
|
167
237
|
raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
|
|
168
|
-
result = typing.cast(
|
|
238
|
+
result = typing.cast(dict[str, JsonType], results[0])
|
|
169
239
|
|
|
170
240
|
id = typing.cast(str, result["id"])
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
comment = extensions.get("comment", "")
|
|
175
|
-
comment = typing.cast(str, comment)
|
|
241
|
+
media_type = typing.cast(str, result["mediaType"])
|
|
242
|
+
file_size = typing.cast(int, result["fileSize"])
|
|
243
|
+
comment = typing.cast(str, result.get("comment", ""))
|
|
176
244
|
return ConfluenceAttachment(id, media_type, file_size, comment)
|
|
177
245
|
|
|
178
246
|
def upload_attachment(
|
|
@@ -205,9 +273,7 @@ class ConfluenceSession:
|
|
|
205
273
|
raise ConfluenceError(f"file not found: {attachment_path}")
|
|
206
274
|
|
|
207
275
|
try:
|
|
208
|
-
attachment = self.get_attachment_by_name(
|
|
209
|
-
page_id, attachment_name, space_key=space_key
|
|
210
|
-
)
|
|
276
|
+
attachment = self.get_attachment_by_name(page_id, attachment_name)
|
|
211
277
|
|
|
212
278
|
if attachment_path is not None:
|
|
213
279
|
if not force and attachment.file_size == attachment_path.stat().st_size:
|
|
@@ -226,7 +292,7 @@ class ConfluenceSession:
|
|
|
226
292
|
except ConfluenceError:
|
|
227
293
|
path = f"/content/{page_id}/child/attachment"
|
|
228
294
|
|
|
229
|
-
url = self._build_url(path)
|
|
295
|
+
url = self._build_url(ConfluenceVersion.VERSION_1, path)
|
|
230
296
|
|
|
231
297
|
if attachment_path is not None:
|
|
232
298
|
with open(attachment_path, "rb") as attachment_file:
|
|
@@ -304,7 +370,7 @@ class ConfluenceSession:
|
|
|
304
370
|
}
|
|
305
371
|
|
|
306
372
|
LOGGER.info("Updating attachment: %s", attachment_id)
|
|
307
|
-
self._save(path, data)
|
|
373
|
+
self._save(ConfluenceVersion.VERSION_1, path, data)
|
|
308
374
|
|
|
309
375
|
def get_page_id_by_title(
|
|
310
376
|
self,
|
|
@@ -321,82 +387,47 @@ class ConfluenceSession:
|
|
|
321
387
|
"""
|
|
322
388
|
|
|
323
389
|
LOGGER.info("Looking up page with title: %s", title)
|
|
324
|
-
path = "/
|
|
325
|
-
query = {
|
|
326
|
-
|
|
390
|
+
path = "/pages"
|
|
391
|
+
query = {
|
|
392
|
+
"space-id": self.space_key_to_id(space_key or self.space_key),
|
|
393
|
+
"title": title,
|
|
394
|
+
}
|
|
395
|
+
payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
396
|
+
payload = typing.cast(dict[str, JsonType], payload)
|
|
327
397
|
|
|
328
|
-
results = typing.cast(
|
|
398
|
+
results = typing.cast(list[JsonType], payload["results"])
|
|
329
399
|
if len(results) != 1:
|
|
330
|
-
raise ConfluenceError(f"page not found with title: {title}")
|
|
400
|
+
raise ConfluenceError(f"unique page not found with title: {title}")
|
|
331
401
|
|
|
332
|
-
result = typing.cast(
|
|
402
|
+
result = typing.cast(dict[str, JsonType], results[0])
|
|
333
403
|
id = typing.cast(str, result["id"])
|
|
334
404
|
return id
|
|
335
405
|
|
|
336
|
-
def get_page(
|
|
337
|
-
self, page_id: str, *, space_key: Optional[str] = None
|
|
338
|
-
) -> ConfluencePage:
|
|
406
|
+
def get_page(self, page_id: str) -> ConfluencePage:
|
|
339
407
|
"""
|
|
340
408
|
Retrieve Confluence wiki page details.
|
|
341
409
|
|
|
342
410
|
:param page_id: The Confluence page ID.
|
|
343
|
-
:param space_key: The Confluence space key (unless the default space is to be used).
|
|
344
411
|
:returns: Confluence page info.
|
|
345
412
|
"""
|
|
346
413
|
|
|
347
|
-
path = f"/
|
|
348
|
-
query = {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
355
|
-
body = typing.cast(Dict[str, JsonType], data["body"])
|
|
356
|
-
storage = typing.cast(Dict[str, JsonType], body["storage"])
|
|
414
|
+
path = f"/pages/{page_id}"
|
|
415
|
+
query = {"body-format": "storage"}
|
|
416
|
+
payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
417
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
418
|
+
version = typing.cast(dict[str, JsonType], data["version"])
|
|
419
|
+
body = typing.cast(dict[str, JsonType], data["body"])
|
|
420
|
+
storage = typing.cast(dict[str, JsonType], body["storage"])
|
|
357
421
|
|
|
358
422
|
return ConfluencePage(
|
|
359
423
|
id=page_id,
|
|
360
|
-
|
|
424
|
+
space_id=typing.cast(str, data["spaceId"]),
|
|
361
425
|
title=typing.cast(str, data["title"]),
|
|
362
426
|
version=typing.cast(int, version["number"]),
|
|
363
427
|
content=typing.cast(str, storage["value"]),
|
|
364
428
|
)
|
|
365
429
|
|
|
366
|
-
def
|
|
367
|
-
self, page_id: str, *, space_key: Optional[str] = None
|
|
368
|
-
) -> Dict[str, str]:
|
|
369
|
-
"""
|
|
370
|
-
Retrieve Confluence wiki page ancestors.
|
|
371
|
-
|
|
372
|
-
:param page_id: The Confluence page ID.
|
|
373
|
-
:param space_key: The Confluence space key (unless the default space is to be used).
|
|
374
|
-
:returns: Dictionary of ancestor page ID to title, with topmost ancestor first.
|
|
375
|
-
"""
|
|
376
|
-
|
|
377
|
-
path = f"/content/{page_id}"
|
|
378
|
-
query = {
|
|
379
|
-
"spaceKey": space_key or self.space_key,
|
|
380
|
-
"expand": "ancestors",
|
|
381
|
-
}
|
|
382
|
-
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
383
|
-
ancestors = typing.cast(List[JsonType], data["ancestors"])
|
|
384
|
-
|
|
385
|
-
# from the JSON array of ancestors, extract the "id" and "title"
|
|
386
|
-
results: Dict[str, str] = {}
|
|
387
|
-
for node in ancestors:
|
|
388
|
-
ancestor = typing.cast(Dict[str, JsonType], node)
|
|
389
|
-
id = typing.cast(str, ancestor["id"])
|
|
390
|
-
title = typing.cast(str, ancestor["title"])
|
|
391
|
-
results[id] = title
|
|
392
|
-
return results
|
|
393
|
-
|
|
394
|
-
def get_page_version(
|
|
395
|
-
self,
|
|
396
|
-
page_id: str,
|
|
397
|
-
*,
|
|
398
|
-
space_key: Optional[str] = None,
|
|
399
|
-
) -> int:
|
|
430
|
+
def get_page_version(self, page_id: str) -> int:
|
|
400
431
|
"""
|
|
401
432
|
Retrieve a Confluence wiki page version.
|
|
402
433
|
|
|
@@ -405,13 +436,10 @@ class ConfluenceSession:
|
|
|
405
436
|
:returns: Confluence page version.
|
|
406
437
|
"""
|
|
407
438
|
|
|
408
|
-
path = f"/
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
414
|
-
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
439
|
+
path = f"/pages/{page_id}"
|
|
440
|
+
payload = self._invoke(ConfluenceVersion.VERSION_2, path)
|
|
441
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
442
|
+
version = typing.cast(dict[str, JsonType], data["version"])
|
|
415
443
|
return typing.cast(int, version["number"])
|
|
416
444
|
|
|
417
445
|
def update_page(
|
|
@@ -419,30 +447,39 @@ class ConfluenceSession:
|
|
|
419
447
|
page_id: str,
|
|
420
448
|
new_content: str,
|
|
421
449
|
*,
|
|
422
|
-
|
|
450
|
+
title: Optional[str] = None,
|
|
423
451
|
) -> None:
|
|
424
|
-
|
|
452
|
+
"""
|
|
453
|
+
Update a page via the Confluence API.
|
|
454
|
+
|
|
455
|
+
:param page_id: The Confluence page ID.
|
|
456
|
+
:param new_content: Confluence Storage Format XHTML.
|
|
457
|
+
:param space_key: The Confluence space key (unless the default space is to be used).
|
|
458
|
+
:param title: New title to assign to the page. Needs to be unique within a space.
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
page = self.get_page(page_id)
|
|
462
|
+
new_title = title or page.title
|
|
425
463
|
|
|
426
464
|
try:
|
|
427
465
|
old_content = sanitize_confluence(page.content)
|
|
428
|
-
if old_content == new_content:
|
|
466
|
+
if page.title == new_title and old_content == new_content:
|
|
429
467
|
LOGGER.info("Up-to-date page: %s", page_id)
|
|
430
468
|
return
|
|
431
469
|
except ParseError as exc:
|
|
432
470
|
LOGGER.warning(exc)
|
|
433
471
|
|
|
434
|
-
path = f"/
|
|
472
|
+
path = f"/pages/{page_id}"
|
|
435
473
|
data = {
|
|
436
474
|
"id": page_id,
|
|
437
|
-
"
|
|
438
|
-
"title":
|
|
439
|
-
"space": {"key": space_key or self.space_key},
|
|
475
|
+
"status": "current",
|
|
476
|
+
"title": new_title,
|
|
440
477
|
"body": {"storage": {"value": new_content, "representation": "storage"}},
|
|
441
478
|
"version": {"minorEdit": True, "number": page.version + 1},
|
|
442
479
|
}
|
|
443
480
|
|
|
444
481
|
LOGGER.info("Updating page: %s", page_id)
|
|
445
|
-
self._save(path, data)
|
|
482
|
+
self._save(ConfluenceVersion.VERSION_2, path, data)
|
|
446
483
|
|
|
447
484
|
def create_page(
|
|
448
485
|
self,
|
|
@@ -452,18 +489,22 @@ class ConfluenceSession:
|
|
|
452
489
|
*,
|
|
453
490
|
space_key: Optional[str] = None,
|
|
454
491
|
) -> ConfluencePage:
|
|
455
|
-
|
|
492
|
+
"""
|
|
493
|
+
Create a new page via Confluence API.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
path = "/pages/"
|
|
456
497
|
query = {
|
|
457
|
-
"
|
|
498
|
+
"spaceId": self.space_key_to_id(space_key or self.space_key),
|
|
499
|
+
"status": "current",
|
|
458
500
|
"title": title,
|
|
459
|
-
"
|
|
501
|
+
"parentId": parent_page_id,
|
|
460
502
|
"body": {"storage": {"value": new_content, "representation": "storage"}},
|
|
461
|
-
"ancestors": [{"type": "page", "id": parent_page_id}],
|
|
462
503
|
}
|
|
463
504
|
|
|
464
505
|
LOGGER.info("Creating page: %s", title)
|
|
465
506
|
|
|
466
|
-
url = self._build_url(path)
|
|
507
|
+
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
467
508
|
response = self.session.post(
|
|
468
509
|
url,
|
|
469
510
|
data=json.dumps(query),
|
|
@@ -471,43 +512,66 @@ class ConfluenceSession:
|
|
|
471
512
|
)
|
|
472
513
|
response.raise_for_status()
|
|
473
514
|
|
|
474
|
-
data = typing.cast(
|
|
475
|
-
version = typing.cast(
|
|
476
|
-
body = typing.cast(
|
|
477
|
-
storage = typing.cast(
|
|
515
|
+
data = typing.cast(dict[str, JsonType], response.json())
|
|
516
|
+
version = typing.cast(dict[str, JsonType], data["version"])
|
|
517
|
+
body = typing.cast(dict[str, JsonType], data["body"])
|
|
518
|
+
storage = typing.cast(dict[str, JsonType], body["storage"])
|
|
478
519
|
|
|
479
520
|
return ConfluencePage(
|
|
480
521
|
id=typing.cast(str, data["id"]),
|
|
481
|
-
|
|
522
|
+
space_id=typing.cast(str, data["spaceId"]),
|
|
482
523
|
title=typing.cast(str, data["title"]),
|
|
483
524
|
version=typing.cast(int, version["number"]),
|
|
484
525
|
content=typing.cast(str, storage["value"]),
|
|
485
526
|
)
|
|
486
527
|
|
|
528
|
+
def delete_page(self, page_id: str, *, purge: bool = False) -> None:
|
|
529
|
+
"""
|
|
530
|
+
Delete a page via Confluence API.
|
|
531
|
+
|
|
532
|
+
:param page_id: The Confluence page ID.
|
|
533
|
+
:param purge: True to completely purge the page, False to move to trash only.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
path = f"/pages/{page_id}"
|
|
537
|
+
|
|
538
|
+
# move to trash
|
|
539
|
+
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
540
|
+
LOGGER.info("Moving page to trash: %s", page_id)
|
|
541
|
+
response = self.session.delete(url)
|
|
542
|
+
response.raise_for_status()
|
|
543
|
+
|
|
544
|
+
if purge:
|
|
545
|
+
# purge from trash
|
|
546
|
+
query = {"purge": "true"}
|
|
547
|
+
url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
|
|
548
|
+
LOGGER.info("Permanently deleting page: %s", page_id)
|
|
549
|
+
response = self.session.delete(url)
|
|
550
|
+
response.raise_for_status()
|
|
551
|
+
|
|
487
552
|
def page_exists(
|
|
488
553
|
self, title: str, *, space_key: Optional[str] = None
|
|
489
554
|
) -> Optional[str]:
|
|
490
|
-
path = "/
|
|
555
|
+
path = "/pages"
|
|
491
556
|
query = {
|
|
492
|
-
"type": "page",
|
|
493
557
|
"title": title,
|
|
494
|
-
"
|
|
558
|
+
"space-id": self.space_key_to_id(space_key or self.space_key),
|
|
495
559
|
}
|
|
496
560
|
|
|
497
561
|
LOGGER.info("Checking if page exists with title: %s", title)
|
|
498
562
|
|
|
499
|
-
url = self._build_url(path)
|
|
563
|
+
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
500
564
|
response = self.session.get(
|
|
501
565
|
url, params=query, headers={"Content-Type": "application/json"}
|
|
502
566
|
)
|
|
503
567
|
response.raise_for_status()
|
|
504
568
|
|
|
505
|
-
data = typing.cast(
|
|
506
|
-
results = typing.cast(
|
|
569
|
+
data = typing.cast(dict[str, JsonType], response.json())
|
|
570
|
+
results = typing.cast(list[JsonType], data["results"])
|
|
507
571
|
|
|
508
572
|
if len(results) == 1:
|
|
509
|
-
|
|
510
|
-
return typing.cast(str,
|
|
573
|
+
result = typing.cast(dict[str, JsonType], results[0])
|
|
574
|
+
return typing.cast(str, result["id"])
|
|
511
575
|
else:
|
|
512
576
|
return None
|
|
513
577
|
|
md2conf/application.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2025, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -9,9 +9,7 @@ Copyright 2022-2024, Levente Hunyadi
|
|
|
9
9
|
import logging
|
|
10
10
|
import os.path
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import
|
|
13
|
-
|
|
14
|
-
import yaml
|
|
12
|
+
from typing import Optional
|
|
15
13
|
|
|
16
14
|
from .api import ConfluencePage, ConfluenceSession
|
|
17
15
|
from .converter import (
|
|
@@ -20,7 +18,7 @@ from .converter import (
|
|
|
20
18
|
ConfluencePageMetadata,
|
|
21
19
|
ConfluenceQualifiedID,
|
|
22
20
|
attachment_name,
|
|
23
|
-
|
|
21
|
+
extract_frontmatter_title,
|
|
24
22
|
extract_qualified_id,
|
|
25
23
|
read_qualified_id,
|
|
26
24
|
)
|
|
@@ -79,7 +77,7 @@ class Application:
|
|
|
79
77
|
LOGGER.info("Synchronizing directory: %s", local_dir)
|
|
80
78
|
|
|
81
79
|
# Step 1: build index of all page metadata
|
|
82
|
-
page_metadata:
|
|
80
|
+
page_metadata: dict[Path, ConfluencePageMetadata] = {}
|
|
83
81
|
root_id = (
|
|
84
82
|
ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
|
|
85
83
|
if self.options.root_page_id
|
|
@@ -96,24 +94,19 @@ class Application:
|
|
|
96
94
|
self,
|
|
97
95
|
page_path: Path,
|
|
98
96
|
root_dir: Path,
|
|
99
|
-
page_metadata:
|
|
97
|
+
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
100
98
|
) -> None:
|
|
101
99
|
base_path = page_path.parent
|
|
102
100
|
|
|
103
101
|
LOGGER.info("Synchronizing page: %s", page_path)
|
|
104
102
|
document = ConfluenceDocument(page_path, self.options, root_dir, page_metadata)
|
|
105
|
-
|
|
106
|
-
if document.id.space_key:
|
|
107
|
-
with self.api.switch_space(document.id.space_key):
|
|
108
|
-
self._update_document(document, base_path)
|
|
109
|
-
else:
|
|
110
|
-
self._update_document(document, base_path)
|
|
103
|
+
self._update_document(document, base_path)
|
|
111
104
|
|
|
112
105
|
def _index_directory(
|
|
113
106
|
self,
|
|
114
107
|
local_dir: Path,
|
|
115
108
|
root_id: Optional[ConfluenceQualifiedID],
|
|
116
|
-
page_metadata:
|
|
109
|
+
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
117
110
|
) -> None:
|
|
118
111
|
"Indexes Markdown files in a directory recursively."
|
|
119
112
|
|
|
@@ -121,8 +114,8 @@ class Application:
|
|
|
121
114
|
|
|
122
115
|
matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
|
|
123
116
|
|
|
124
|
-
files:
|
|
125
|
-
directories:
|
|
117
|
+
files: list[Path] = []
|
|
118
|
+
directories: list[Path] = []
|
|
126
119
|
for entry in os.scandir(local_dir):
|
|
127
120
|
if matcher.is_excluded(entry.name, entry.is_dir()):
|
|
128
121
|
continue
|
|
@@ -174,12 +167,10 @@ class Application:
|
|
|
174
167
|
document = f.read()
|
|
175
168
|
|
|
176
169
|
qualified_id, document = extract_qualified_id(document)
|
|
177
|
-
|
|
170
|
+
frontmatter_title, _ = extract_frontmatter_title(document)
|
|
178
171
|
|
|
179
172
|
if qualified_id is not None:
|
|
180
|
-
confluence_page = self.api.get_page(
|
|
181
|
-
qualified_id.page_id, space_key=qualified_id.space_key
|
|
182
|
-
)
|
|
173
|
+
confluence_page = self.api.get_page(qualified_id.page_id)
|
|
183
174
|
else:
|
|
184
175
|
if parent_id is None:
|
|
185
176
|
raise ValueError(
|
|
@@ -187,22 +178,21 @@ class Application:
|
|
|
187
178
|
)
|
|
188
179
|
|
|
189
180
|
# assign title from frontmatter if present
|
|
190
|
-
if title is None and frontmatter is not None:
|
|
191
|
-
properties = yaml.safe_load(frontmatter)
|
|
192
|
-
if isinstance(properties, dict):
|
|
193
|
-
property_title = properties.get("title")
|
|
194
|
-
if isinstance(property_title, str):
|
|
195
|
-
title = property_title
|
|
196
|
-
|
|
197
181
|
confluence_page = self._create_page(
|
|
198
|
-
absolute_path, document, title, parent_id
|
|
182
|
+
absolute_path, document, title or frontmatter_title, parent_id
|
|
199
183
|
)
|
|
200
184
|
|
|
185
|
+
space_key = (
|
|
186
|
+
self.api.space_id_to_key(confluence_page.space_id)
|
|
187
|
+
if confluence_page.space_id
|
|
188
|
+
else self.api.space_key
|
|
189
|
+
)
|
|
190
|
+
|
|
201
191
|
return ConfluencePageMetadata(
|
|
202
192
|
domain=self.api.domain,
|
|
203
193
|
base_path=self.api.base_path,
|
|
204
194
|
page_id=confluence_page.id,
|
|
205
|
-
space_key=
|
|
195
|
+
space_key=space_key,
|
|
206
196
|
title=confluence_page.title or "",
|
|
207
197
|
)
|
|
208
198
|
|
|
@@ -226,7 +216,7 @@ class Application:
|
|
|
226
216
|
absolute_path,
|
|
227
217
|
document,
|
|
228
218
|
confluence_page.id,
|
|
229
|
-
confluence_page.
|
|
219
|
+
self.api.space_id_to_key(confluence_page.space_id),
|
|
230
220
|
)
|
|
231
221
|
return confluence_page
|
|
232
222
|
|
|
@@ -249,7 +239,7 @@ class Application:
|
|
|
249
239
|
|
|
250
240
|
content = document.xhtml()
|
|
251
241
|
LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
|
|
252
|
-
self.api.update_page(document.id.page_id, content)
|
|
242
|
+
self.api.update_page(document.id.page_id, content, title=document.title)
|
|
253
243
|
|
|
254
244
|
def _update_markdown(
|
|
255
245
|
self,
|
|
@@ -260,7 +250,7 @@ class Application:
|
|
|
260
250
|
) -> None:
|
|
261
251
|
"Writes the Confluence page ID and space key at the beginning of the Markdown file."
|
|
262
252
|
|
|
263
|
-
content:
|
|
253
|
+
content: list[str] = []
|
|
264
254
|
|
|
265
255
|
# check if the file has frontmatter
|
|
266
256
|
index = 0
|