markdown-to-confluence 0.3.5__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.5.dist-info → markdown_to_confluence-0.4.0.dist-info}/METADATA +118 -7
- markdown_to_confluence-0.4.0.dist-info/RECORD +25 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +18 -7
- md2conf/api.py +372 -186
- md2conf/application.py +82 -70
- md2conf/collection.py +31 -0
- md2conf/converter.py +17 -10
- md2conf/emoji.py +28 -3
- md2conf/extra.py +14 -0
- md2conf/local.py +30 -35
- md2conf/metadata.py +0 -2
- md2conf/processor.py +134 -38
- md2conf/properties.py +24 -5
- md2conf/scanner.py +53 -21
- markdown_to_confluence-0.3.5.dist-info/RECORD +0 -23
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.0.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.0.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.0.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.3.5.dist-info → markdown_to_confluence-0.4.0.dist-info}/zip-safe +0 -0
md2conf/api.py
CHANGED
|
@@ -6,20 +6,27 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import datetime
|
|
9
10
|
import enum
|
|
10
11
|
import functools
|
|
11
12
|
import io
|
|
12
|
-
import json
|
|
13
13
|
import logging
|
|
14
14
|
import mimetypes
|
|
15
15
|
import typing
|
|
16
16
|
from dataclasses import dataclass
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from types import TracebackType
|
|
19
|
-
from typing import Optional,
|
|
19
|
+
from typing import Optional, TypeVar
|
|
20
20
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
21
21
|
|
|
22
22
|
import requests
|
|
23
|
+
from strong_typing.core import JsonType
|
|
24
|
+
from strong_typing.serialization import (
|
|
25
|
+
DeserializerOptions,
|
|
26
|
+
json_dump_string,
|
|
27
|
+
json_to_object,
|
|
28
|
+
object_to_json,
|
|
29
|
+
)
|
|
23
30
|
|
|
24
31
|
from .converter import ParseError, sanitize_confluence
|
|
25
32
|
from .metadata import ConfluenceSiteMetadata
|
|
@@ -30,18 +37,36 @@ from .properties import (
|
|
|
30
37
|
PageError,
|
|
31
38
|
)
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
T = TypeVar("T")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json_to_object(
|
|
44
|
+
typ: type[T],
|
|
45
|
+
data: JsonType,
|
|
46
|
+
) -> T:
|
|
47
|
+
return json_to_object(typ, data, options=DeserializerOptions(skip_unassigned=True))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
|
|
51
|
+
"Builds a URL with scheme, host, port, path and query string parameters."
|
|
43
52
|
|
|
53
|
+
scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
|
|
44
54
|
|
|
55
|
+
if params:
|
|
56
|
+
raise ValueError("expected: url with no parameters")
|
|
57
|
+
if query_str:
|
|
58
|
+
raise ValueError("expected: url with no query string")
|
|
59
|
+
if fragment:
|
|
60
|
+
raise ValueError("expected: url with no fragment")
|
|
61
|
+
|
|
62
|
+
url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
|
|
63
|
+
return urlunparse(url_parts)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
LOGGER = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@enum.unique
|
|
45
70
|
class ConfluenceVersion(enum.Enum):
|
|
46
71
|
"""
|
|
47
72
|
Confluence REST API version an HTTP request corresponds to.
|
|
@@ -57,6 +82,7 @@ class ConfluenceVersion(enum.Enum):
|
|
|
57
82
|
VERSION_2 = "api/v2"
|
|
58
83
|
|
|
59
84
|
|
|
85
|
+
@enum.unique
|
|
60
86
|
class ConfluencePageParentContentType(enum.Enum):
|
|
61
87
|
"""
|
|
62
88
|
Content types that can be a parent to a Confluence page.
|
|
@@ -69,23 +95,43 @@ class ConfluencePageParentContentType(enum.Enum):
|
|
|
69
95
|
FOLDER = "folder"
|
|
70
96
|
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
@enum.unique
|
|
99
|
+
class ConfluenceRepresentation(enum.Enum):
|
|
100
|
+
STORAGE = "storage"
|
|
101
|
+
ATLAS = "atlas_doc_format"
|
|
102
|
+
WIKI = "wiki"
|
|
74
103
|
|
|
75
|
-
scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if fragment:
|
|
82
|
-
raise ValueError("expected: url with no fragment")
|
|
105
|
+
@enum.unique
|
|
106
|
+
class ConfluenceStatus(enum.Enum):
|
|
107
|
+
CURRENT = "current"
|
|
108
|
+
DRAFT = "draft"
|
|
83
109
|
|
|
84
|
-
url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
|
|
85
|
-
return urlunparse(url_parts)
|
|
86
110
|
|
|
111
|
+
@enum.unique
|
|
112
|
+
class ConfluenceLegacyType(enum.Enum):
|
|
113
|
+
ATTACHMENT = "attachment"
|
|
87
114
|
|
|
88
|
-
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class ConfluenceLinks:
|
|
118
|
+
next: str
|
|
119
|
+
base: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class ConfluenceResultSet:
|
|
124
|
+
results: list[JsonType]
|
|
125
|
+
_links: ConfluenceLinks
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class ConfluenceContentVersion:
|
|
130
|
+
number: int
|
|
131
|
+
minorEdit: bool = False
|
|
132
|
+
createdAt: Optional[datetime.datetime] = None
|
|
133
|
+
message: Optional[str] = None
|
|
134
|
+
authorId: Optional[str] = None
|
|
89
135
|
|
|
90
136
|
|
|
91
137
|
@dataclass(frozen=True)
|
|
@@ -94,15 +140,33 @@ class ConfluenceAttachment:
|
|
|
94
140
|
Holds data for an object uploaded to Confluence as a page attachment.
|
|
95
141
|
|
|
96
142
|
:param id: Unique ID for the attachment.
|
|
97
|
-
:param
|
|
98
|
-
:param
|
|
143
|
+
:param status: Attachment status.
|
|
144
|
+
:param title: Attachment title.
|
|
145
|
+
:param createdAt: Date and time when the attachment was created.
|
|
146
|
+
:param pageId: The Confluence page that the attachment is coupled with.
|
|
147
|
+
:param mediaType: MIME type for the attachment.
|
|
148
|
+
:param mediaTypeDescription: Media type description for the attachment.
|
|
99
149
|
:param comment: Description for the attachment.
|
|
150
|
+
:param fileId: File ID of the attachment, distinct from the attachment ID.
|
|
151
|
+
:param fileSize: Size in bytes.
|
|
152
|
+
:param webuiLink: WebUI link of the attachment.
|
|
153
|
+
:param downloadLink: Download link of the attachment.
|
|
154
|
+
:param version: Version information for the attachment.
|
|
100
155
|
"""
|
|
101
156
|
|
|
102
157
|
id: str
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
158
|
+
status: ConfluenceStatus
|
|
159
|
+
title: Optional[str]
|
|
160
|
+
createdAt: datetime.datetime
|
|
161
|
+
pageId: str
|
|
162
|
+
mediaType: str
|
|
163
|
+
mediaTypeDescription: str
|
|
164
|
+
comment: Optional[str]
|
|
165
|
+
fileId: str
|
|
166
|
+
fileSize: int
|
|
167
|
+
webuiLink: str
|
|
168
|
+
downloadLink: str
|
|
169
|
+
version: ConfluenceContentVersion
|
|
106
170
|
|
|
107
171
|
|
|
108
172
|
@dataclass(frozen=True)
|
|
@@ -111,19 +175,55 @@ class ConfluencePageProperties:
|
|
|
111
175
|
Holds Confluence page properties used for page synchronization.
|
|
112
176
|
|
|
113
177
|
:param id: Confluence page ID.
|
|
114
|
-
:param
|
|
115
|
-
:param parent_id: Confluence page ID of the immediate parent.
|
|
116
|
-
:param parent_type: Identifies the content type of the parent.
|
|
178
|
+
:param status: Page status.
|
|
117
179
|
:param title: Page title.
|
|
180
|
+
:param spaceId: Confluence space ID.
|
|
181
|
+
:param parentId: Confluence page ID of the immediate parent.
|
|
182
|
+
:param parentType: Identifies the content type of the parent.
|
|
183
|
+
:param position: Position of child page within the given parent page tree.
|
|
184
|
+
:param authorId: The account ID of the user who created this page originally.
|
|
185
|
+
:param ownerId: The account ID of the user who owns this page.
|
|
186
|
+
:param lastOwnerId: The account ID of the user who owned this page previously, or `None` if there is no previous owner.
|
|
187
|
+
:param createdAt: Date and time when the page was created.
|
|
118
188
|
:param version: Page version. Incremented when the page is updated.
|
|
119
189
|
"""
|
|
120
190
|
|
|
121
191
|
id: str
|
|
122
|
-
|
|
123
|
-
parent_id: str
|
|
124
|
-
parent_type: Optional[ConfluencePageParentContentType]
|
|
192
|
+
status: ConfluenceStatus
|
|
125
193
|
title: str
|
|
126
|
-
|
|
194
|
+
spaceId: str
|
|
195
|
+
parentId: Optional[str]
|
|
196
|
+
parentType: Optional[ConfluencePageParentContentType]
|
|
197
|
+
position: Optional[int]
|
|
198
|
+
authorId: str
|
|
199
|
+
ownerId: str
|
|
200
|
+
lastOwnerId: Optional[str]
|
|
201
|
+
createdAt: datetime.datetime
|
|
202
|
+
version: ConfluenceContentVersion
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class ConfluencePageStorage:
|
|
207
|
+
"""
|
|
208
|
+
Holds Confluence page content.
|
|
209
|
+
|
|
210
|
+
:param representation: Type of content representation used (e.g. Confluence Storage Format).
|
|
211
|
+
:param value: Body of the content, in the format found in the representation field.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
representation: ConfluenceRepresentation
|
|
215
|
+
value: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class ConfluencePageBody:
|
|
220
|
+
"""
|
|
221
|
+
Holds Confluence page content.
|
|
222
|
+
|
|
223
|
+
:param storage: Encapsulates content with meta-information about its representation.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
storage: ConfluencePageStorage
|
|
127
227
|
|
|
128
228
|
|
|
129
229
|
@dataclass(frozen=True)
|
|
@@ -131,27 +231,67 @@ class ConfluencePage(ConfluencePageProperties):
|
|
|
131
231
|
"""
|
|
132
232
|
Holds Confluence page data used for page synchronization.
|
|
133
233
|
|
|
134
|
-
:param
|
|
234
|
+
:param body: Page content.
|
|
135
235
|
"""
|
|
136
236
|
|
|
137
|
-
|
|
237
|
+
body: ConfluencePageBody
|
|
138
238
|
|
|
239
|
+
@property
|
|
240
|
+
def content(self) -> str:
|
|
241
|
+
return self.body.storage.value
|
|
139
242
|
|
|
140
|
-
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, eq=True, order=True)
|
|
141
245
|
class ConfluenceLabel:
|
|
142
246
|
"""
|
|
143
247
|
Holds information about a single label.
|
|
144
248
|
|
|
145
|
-
:param id: ID of the label.
|
|
146
249
|
:param name: Name of the label.
|
|
147
250
|
:param prefix: Prefix of the label.
|
|
148
251
|
"""
|
|
149
252
|
|
|
150
|
-
id: str
|
|
151
253
|
name: str
|
|
152
254
|
prefix: str
|
|
153
255
|
|
|
154
256
|
|
|
257
|
+
@dataclass(frozen=True, eq=True, order=True)
|
|
258
|
+
class ConfluenceIdentifiedLabel(ConfluenceLabel):
|
|
259
|
+
"""
|
|
260
|
+
Holds information about a single label.
|
|
261
|
+
|
|
262
|
+
:param id: ID of the label.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
id: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class ConfluenceCreatePageRequest:
|
|
270
|
+
spaceId: str
|
|
271
|
+
status: Optional[ConfluenceStatus]
|
|
272
|
+
title: Optional[str]
|
|
273
|
+
parentId: Optional[str]
|
|
274
|
+
body: ConfluencePageBody
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class ConfluenceUpdatePageRequest:
|
|
279
|
+
id: str
|
|
280
|
+
status: ConfluenceStatus
|
|
281
|
+
title: str
|
|
282
|
+
body: ConfluencePageBody
|
|
283
|
+
version: ConfluenceContentVersion
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class ConfluenceUpdateAttachmentRequest:
|
|
288
|
+
id: str
|
|
289
|
+
type: ConfluenceLegacyType
|
|
290
|
+
status: ConfluenceStatus
|
|
291
|
+
title: str
|
|
292
|
+
version: ConfluenceContentVersion
|
|
293
|
+
|
|
294
|
+
|
|
155
295
|
class ConfluenceAPI:
|
|
156
296
|
"""
|
|
157
297
|
Represents an active connection to a Confluence server.
|
|
@@ -179,9 +319,10 @@ class ConfluenceAPI:
|
|
|
179
319
|
|
|
180
320
|
self.session = ConfluenceSession(
|
|
181
321
|
session,
|
|
182
|
-
self.properties.
|
|
183
|
-
self.properties.
|
|
184
|
-
self.properties.
|
|
322
|
+
api_url=self.properties.api_url,
|
|
323
|
+
domain=self.properties.domain,
|
|
324
|
+
base_path=self.properties.base_path,
|
|
325
|
+
space_key=self.properties.space_key,
|
|
185
326
|
)
|
|
186
327
|
return self.session
|
|
187
328
|
|
|
@@ -202,6 +343,7 @@ class ConfluenceSession:
|
|
|
202
343
|
"""
|
|
203
344
|
|
|
204
345
|
session: requests.Session
|
|
346
|
+
api_url: str
|
|
205
347
|
site: ConfluenceSiteMetadata
|
|
206
348
|
|
|
207
349
|
_space_id_to_key: dict[str, str]
|
|
@@ -210,16 +352,42 @@ class ConfluenceSession:
|
|
|
210
352
|
def __init__(
|
|
211
353
|
self,
|
|
212
354
|
session: requests.Session,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
355
|
+
*,
|
|
356
|
+
api_url: Optional[str],
|
|
357
|
+
domain: Optional[str],
|
|
358
|
+
base_path: Optional[str],
|
|
359
|
+
space_key: Optional[str],
|
|
216
360
|
) -> None:
|
|
217
361
|
self.session = session
|
|
218
|
-
self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
|
|
219
|
-
|
|
220
362
|
self._space_id_to_key = {}
|
|
221
363
|
self._space_key_to_id = {}
|
|
222
364
|
|
|
365
|
+
if api_url:
|
|
366
|
+
self.api_url = api_url
|
|
367
|
+
|
|
368
|
+
if not domain or not base_path:
|
|
369
|
+
payload = self._invoke(
|
|
370
|
+
ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"}
|
|
371
|
+
)
|
|
372
|
+
data = json_to_object(ConfluenceResultSet, payload)
|
|
373
|
+
base_url = data._links.base
|
|
374
|
+
|
|
375
|
+
_, domain, base_path, _, _, _ = urlparse(base_url)
|
|
376
|
+
if not base_path.endswith("/"):
|
|
377
|
+
base_path = f"{base_path}/"
|
|
378
|
+
|
|
379
|
+
if not domain:
|
|
380
|
+
raise ArgumentError(
|
|
381
|
+
"Confluence domain not specified and cannot be inferred"
|
|
382
|
+
)
|
|
383
|
+
if not base_path:
|
|
384
|
+
raise ArgumentError(
|
|
385
|
+
"Confluence base path not specified and cannot be inferred"
|
|
386
|
+
)
|
|
387
|
+
self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
|
|
388
|
+
if not api_url:
|
|
389
|
+
self.api_url = f"https://{self.site.domain}{self.site.base_path}"
|
|
390
|
+
|
|
223
391
|
def close(self) -> None:
|
|
224
392
|
self.session.close()
|
|
225
393
|
self.session = requests.Session()
|
|
@@ -239,9 +407,7 @@ class ConfluenceSession:
|
|
|
239
407
|
:returns: A full URL.
|
|
240
408
|
"""
|
|
241
409
|
|
|
242
|
-
base_url =
|
|
243
|
-
f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
|
|
244
|
-
)
|
|
410
|
+
base_url = f"{self.api_url}{version.value}{path}"
|
|
245
411
|
return build_url(base_url, query)
|
|
246
412
|
|
|
247
413
|
def _invoke(
|
|
@@ -253,11 +419,11 @@ class ConfluenceSession:
|
|
|
253
419
|
"Executes an HTTP request via Confluence API."
|
|
254
420
|
|
|
255
421
|
url = self._build_url(version, path, query)
|
|
256
|
-
response = self.session.get(url)
|
|
422
|
+
response = self.session.get(url, headers={"Accept": "application/json"})
|
|
257
423
|
if response.text:
|
|
258
424
|
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
259
425
|
response.raise_for_status()
|
|
260
|
-
return response.json()
|
|
426
|
+
return typing.cast(JsonType, response.json())
|
|
261
427
|
|
|
262
428
|
def _fetch(
|
|
263
429
|
self, path: str, query: Optional[dict[str, str]] = None
|
|
@@ -267,7 +433,7 @@ class ConfluenceSession:
|
|
|
267
433
|
items: list[JsonType] = []
|
|
268
434
|
url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
|
|
269
435
|
while True:
|
|
270
|
-
response = self.session.get(url)
|
|
436
|
+
response = self.session.get(url, headers={"Accept": "application/json"})
|
|
271
437
|
response.raise_for_status()
|
|
272
438
|
|
|
273
439
|
payload = typing.cast(dict[str, JsonType], response.json())
|
|
@@ -289,7 +455,7 @@ class ConfluenceSession:
|
|
|
289
455
|
url = self._build_url(version, path)
|
|
290
456
|
response = self.session.put(
|
|
291
457
|
url,
|
|
292
|
-
data=
|
|
458
|
+
data=json_dump_string(data),
|
|
293
459
|
headers={"Content-Type": "application/json"},
|
|
294
460
|
)
|
|
295
461
|
if response.text:
|
|
@@ -306,8 +472,8 @@ class ConfluenceSession:
|
|
|
306
472
|
"/spaces",
|
|
307
473
|
{"ids": id, "status": "current"},
|
|
308
474
|
)
|
|
309
|
-
|
|
310
|
-
results = typing.cast(list[JsonType],
|
|
475
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
476
|
+
results = typing.cast(list[JsonType], data["results"])
|
|
311
477
|
if len(results) != 1:
|
|
312
478
|
raise ConfluenceError(f"unique space not found with id: {id}")
|
|
313
479
|
|
|
@@ -328,8 +494,8 @@ class ConfluenceSession:
|
|
|
328
494
|
"/spaces",
|
|
329
495
|
{"keys": key, "status": "current"},
|
|
330
496
|
)
|
|
331
|
-
|
|
332
|
-
results = typing.cast(list[JsonType],
|
|
497
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
498
|
+
results = typing.cast(list[JsonType], data["results"])
|
|
333
499
|
if len(results) != 1:
|
|
334
500
|
raise ConfluenceError(f"unique space not found with key: {key}")
|
|
335
501
|
|
|
@@ -372,20 +538,14 @@ class ConfluenceSession:
|
|
|
372
538
|
|
|
373
539
|
path = f"/pages/{page_id}/attachments"
|
|
374
540
|
query = {"filename": filename}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
)
|
|
541
|
+
payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
542
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
378
543
|
|
|
379
544
|
results = typing.cast(list[JsonType], data["results"])
|
|
380
545
|
if len(results) != 1:
|
|
381
546
|
raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
|
|
382
547
|
result = typing.cast(dict[str, JsonType], results[0])
|
|
383
|
-
|
|
384
|
-
id = typing.cast(str, result["id"])
|
|
385
|
-
media_type = typing.cast(str, result["mediaType"])
|
|
386
|
-
file_size = typing.cast(int, result["fileSize"])
|
|
387
|
-
comment = typing.cast(str, result.get("comment", ""))
|
|
388
|
-
return ConfluenceAttachment(id, media_type, file_size, comment)
|
|
548
|
+
return _json_to_object(ConfluenceAttachment, result)
|
|
389
549
|
|
|
390
550
|
def upload_attachment(
|
|
391
551
|
self,
|
|
@@ -430,15 +590,15 @@ class ConfluenceSession:
|
|
|
430
590
|
attachment = self.get_attachment_by_name(page_id, attachment_name)
|
|
431
591
|
|
|
432
592
|
if attachment_path is not None:
|
|
433
|
-
if not force and attachment.
|
|
593
|
+
if not force and attachment.fileSize == attachment_path.stat().st_size:
|
|
434
594
|
LOGGER.info("Up-to-date attachment: %s", attachment_name)
|
|
435
595
|
return
|
|
436
596
|
elif raw_data is not None:
|
|
437
|
-
if not force and attachment.
|
|
597
|
+
if not force and attachment.fileSize == len(raw_data):
|
|
438
598
|
LOGGER.info("Up-to-date embedded image: %s", attachment_name)
|
|
439
599
|
return
|
|
440
600
|
else:
|
|
441
|
-
raise NotImplementedError("
|
|
601
|
+
raise NotImplementedError("parameter match not exhaustive")
|
|
442
602
|
|
|
443
603
|
id = attachment.id.removeprefix("att")
|
|
444
604
|
path = f"/content/{page_id}/child/attachment/{id}/data"
|
|
@@ -462,8 +622,11 @@ class ConfluenceSession:
|
|
|
462
622
|
LOGGER.info("Uploading attachment: %s", attachment_name)
|
|
463
623
|
response = self.session.post(
|
|
464
624
|
url,
|
|
465
|
-
files=file_to_upload, # type: ignore
|
|
466
|
-
headers={
|
|
625
|
+
files=file_to_upload, # type: ignore[arg-type]
|
|
626
|
+
headers={
|
|
627
|
+
"X-Atlassian-Token": "no-check",
|
|
628
|
+
"Accept": "application/json",
|
|
629
|
+
},
|
|
467
630
|
)
|
|
468
631
|
elif raw_data is not None:
|
|
469
632
|
LOGGER.info("Uploading raw data: %s", attachment_name)
|
|
@@ -474,18 +637,21 @@ class ConfluenceSession:
|
|
|
474
637
|
"comment": comment,
|
|
475
638
|
"file": (
|
|
476
639
|
attachment_name, # will truncate path component
|
|
477
|
-
raw_file, # type: ignore
|
|
640
|
+
raw_file, # type: ignore[dict-item]
|
|
478
641
|
content_type,
|
|
479
642
|
{"Expires": "0"},
|
|
480
643
|
),
|
|
481
644
|
}
|
|
482
645
|
response = self.session.post(
|
|
483
646
|
url,
|
|
484
|
-
files=file_to_upload, # type: ignore
|
|
485
|
-
headers={
|
|
647
|
+
files=file_to_upload, # type: ignore[arg-type]
|
|
648
|
+
headers={
|
|
649
|
+
"X-Atlassian-Token": "no-check",
|
|
650
|
+
"Accept": "application/json",
|
|
651
|
+
},
|
|
486
652
|
)
|
|
487
653
|
else:
|
|
488
|
-
raise NotImplementedError("
|
|
654
|
+
raise NotImplementedError("parameter match not exhaustive")
|
|
489
655
|
|
|
490
656
|
response.raise_for_status()
|
|
491
657
|
data = response.json()
|
|
@@ -506,24 +672,24 @@ class ConfluenceSession:
|
|
|
506
672
|
) -> None:
|
|
507
673
|
id = attachment_id.removeprefix("att")
|
|
508
674
|
path = f"/content/{page_id}/child/attachment/{id}"
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
675
|
+
request = ConfluenceUpdateAttachmentRequest(
|
|
676
|
+
id=attachment_id,
|
|
677
|
+
type=ConfluenceLegacyType.ATTACHMENT,
|
|
678
|
+
status=ConfluenceStatus.CURRENT,
|
|
679
|
+
title=attachment_title,
|
|
680
|
+
version=ConfluenceContentVersion(number=version, minorEdit=True),
|
|
681
|
+
)
|
|
516
682
|
|
|
517
683
|
LOGGER.info("Updating attachment: %s", attachment_id)
|
|
518
|
-
self._save(ConfluenceVersion.VERSION_1, path,
|
|
684
|
+
self._save(ConfluenceVersion.VERSION_1, path, object_to_json(request))
|
|
519
685
|
|
|
520
|
-
def
|
|
686
|
+
def get_page_properties_by_title(
|
|
521
687
|
self,
|
|
522
688
|
title: str,
|
|
523
689
|
*,
|
|
524
690
|
space_id: Optional[str] = None,
|
|
525
691
|
space_key: Optional[str] = None,
|
|
526
|
-
) ->
|
|
692
|
+
) -> ConfluencePageProperties:
|
|
527
693
|
"""
|
|
528
694
|
Looks up a Confluence wiki page ID by title.
|
|
529
695
|
|
|
@@ -543,15 +709,13 @@ class ConfluenceSession:
|
|
|
543
709
|
query["space-id"] = space_id
|
|
544
710
|
|
|
545
711
|
payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
results = typing.cast(list[JsonType], payload["results"])
|
|
712
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
713
|
+
results = typing.cast(list[JsonType], data["results"])
|
|
549
714
|
if len(results) != 1:
|
|
550
715
|
raise ConfluenceError(f"unique page not found with title: {title}")
|
|
551
716
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return id
|
|
717
|
+
page = _json_to_object(ConfluencePageProperties, results[0])
|
|
718
|
+
return page
|
|
555
719
|
|
|
556
720
|
def get_page(self, page_id: str) -> ConfluencePage:
|
|
557
721
|
"""
|
|
@@ -564,24 +728,7 @@ class ConfluenceSession:
|
|
|
564
728
|
path = f"/pages/{page_id}"
|
|
565
729
|
query = {"body-format": "storage"}
|
|
566
730
|
payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
567
|
-
|
|
568
|
-
version = typing.cast(dict[str, JsonType], data["version"])
|
|
569
|
-
body = typing.cast(dict[str, JsonType], data["body"])
|
|
570
|
-
storage = typing.cast(dict[str, JsonType], body["storage"])
|
|
571
|
-
|
|
572
|
-
return ConfluencePage(
|
|
573
|
-
id=page_id,
|
|
574
|
-
space_id=typing.cast(str, data["spaceId"]),
|
|
575
|
-
parent_id=typing.cast(str, data["parentId"]),
|
|
576
|
-
parent_type=(
|
|
577
|
-
ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
|
|
578
|
-
if data["parentType"] is not None
|
|
579
|
-
else None
|
|
580
|
-
),
|
|
581
|
-
title=typing.cast(str, data["title"]),
|
|
582
|
-
version=typing.cast(int, version["number"]),
|
|
583
|
-
content=typing.cast(str, storage["value"]),
|
|
584
|
-
)
|
|
731
|
+
return _json_to_object(ConfluencePage, payload)
|
|
585
732
|
|
|
586
733
|
@functools.cache
|
|
587
734
|
def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
|
|
@@ -594,21 +741,7 @@ class ConfluenceSession:
|
|
|
594
741
|
|
|
595
742
|
path = f"/pages/{page_id}"
|
|
596
743
|
payload = self._invoke(ConfluenceVersion.VERSION_2, path)
|
|
597
|
-
|
|
598
|
-
version = typing.cast(dict[str, JsonType], data["version"])
|
|
599
|
-
|
|
600
|
-
return ConfluencePageProperties(
|
|
601
|
-
id=page_id,
|
|
602
|
-
space_id=typing.cast(str, data["spaceId"]),
|
|
603
|
-
parent_id=typing.cast(str, data["parentId"]),
|
|
604
|
-
parent_type=(
|
|
605
|
-
ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
|
|
606
|
-
if data["parentType"] is not None
|
|
607
|
-
else None
|
|
608
|
-
),
|
|
609
|
-
title=typing.cast(str, data["title"]),
|
|
610
|
-
version=typing.cast(int, version["number"]),
|
|
611
|
-
)
|
|
744
|
+
return _json_to_object(ConfluencePageProperties, payload)
|
|
612
745
|
|
|
613
746
|
def get_page_version(self, page_id: str) -> int:
|
|
614
747
|
"""
|
|
@@ -618,11 +751,7 @@ class ConfluenceSession:
|
|
|
618
751
|
:returns: Confluence page version.
|
|
619
752
|
"""
|
|
620
753
|
|
|
621
|
-
|
|
622
|
-
payload = self._invoke(ConfluenceVersion.VERSION_2, path)
|
|
623
|
-
data = typing.cast(dict[str, JsonType], payload)
|
|
624
|
-
version = typing.cast(dict[str, JsonType], data["version"])
|
|
625
|
-
return typing.cast(int, version["number"])
|
|
754
|
+
return self.get_page_properties(page_id).version.number
|
|
626
755
|
|
|
627
756
|
def update_page(
|
|
628
757
|
self,
|
|
@@ -651,16 +780,21 @@ class ConfluenceSession:
|
|
|
651
780
|
LOGGER.warning(exc)
|
|
652
781
|
|
|
653
782
|
path = f"/pages/{page_id}"
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
783
|
+
request = ConfluenceUpdatePageRequest(
|
|
784
|
+
id=page_id,
|
|
785
|
+
status=ConfluenceStatus.CURRENT,
|
|
786
|
+
title=new_title,
|
|
787
|
+
body=ConfluencePageBody(
|
|
788
|
+
storage=ConfluencePageStorage(
|
|
789
|
+
representation=ConfluenceRepresentation.STORAGE, value=new_content
|
|
790
|
+
)
|
|
791
|
+
),
|
|
792
|
+
version=ConfluenceContentVersion(
|
|
793
|
+
number=page.version.number + 1, minorEdit=True
|
|
794
|
+
),
|
|
795
|
+
)
|
|
662
796
|
LOGGER.info("Updating page: %s", page_id)
|
|
663
|
-
self._save(ConfluenceVersion.VERSION_2, path,
|
|
797
|
+
self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
|
|
664
798
|
|
|
665
799
|
def create_page(
|
|
666
800
|
self,
|
|
@@ -672,44 +806,35 @@ class ConfluenceSession:
|
|
|
672
806
|
Creates a new page via Confluence API.
|
|
673
807
|
"""
|
|
674
808
|
|
|
809
|
+
LOGGER.info("Creating page: %s", title)
|
|
810
|
+
|
|
675
811
|
parent_page = self.get_page_properties(parent_id)
|
|
676
|
-
path = "/pages/"
|
|
677
|
-
query = {
|
|
678
|
-
"spaceId": parent_page.space_id,
|
|
679
|
-
"status": "current",
|
|
680
|
-
"title": title,
|
|
681
|
-
"parentId": parent_id,
|
|
682
|
-
"body": {"storage": {"value": new_content, "representation": "storage"}},
|
|
683
|
-
}
|
|
684
812
|
|
|
685
|
-
|
|
813
|
+
path = "/pages/"
|
|
814
|
+
request = ConfluenceCreatePageRequest(
|
|
815
|
+
spaceId=parent_page.spaceId,
|
|
816
|
+
status=ConfluenceStatus.CURRENT,
|
|
817
|
+
title=title,
|
|
818
|
+
parentId=parent_id,
|
|
819
|
+
body=ConfluencePageBody(
|
|
820
|
+
storage=ConfluencePageStorage(
|
|
821
|
+
representation=ConfluenceRepresentation.STORAGE,
|
|
822
|
+
value=new_content,
|
|
823
|
+
)
|
|
824
|
+
),
|
|
825
|
+
)
|
|
686
826
|
|
|
687
827
|
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
688
828
|
response = self.session.post(
|
|
689
829
|
url,
|
|
690
|
-
data=
|
|
691
|
-
headers={
|
|
830
|
+
data=json_dump_string(object_to_json(request)),
|
|
831
|
+
headers={
|
|
832
|
+
"Content-Type": "application/json",
|
|
833
|
+
"Accept": "application/json",
|
|
834
|
+
},
|
|
692
835
|
)
|
|
693
836
|
response.raise_for_status()
|
|
694
|
-
|
|
695
|
-
data = typing.cast(dict[str, JsonType], response.json())
|
|
696
|
-
version = typing.cast(dict[str, JsonType], data["version"])
|
|
697
|
-
body = typing.cast(dict[str, JsonType], data["body"])
|
|
698
|
-
storage = typing.cast(dict[str, JsonType], body["storage"])
|
|
699
|
-
|
|
700
|
-
return ConfluencePage(
|
|
701
|
-
id=typing.cast(str, data["id"]),
|
|
702
|
-
space_id=typing.cast(str, data["spaceId"]),
|
|
703
|
-
parent_id=typing.cast(str, data["parentId"]),
|
|
704
|
-
parent_type=(
|
|
705
|
-
ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
|
|
706
|
-
if data["parentType"] is not None
|
|
707
|
-
else None
|
|
708
|
-
),
|
|
709
|
-
title=typing.cast(str, data["title"]),
|
|
710
|
-
version=typing.cast(int, version["number"]),
|
|
711
|
-
content=typing.cast(str, storage["value"]),
|
|
712
|
-
)
|
|
837
|
+
return _json_to_object(ConfluencePage, response.json())
|
|
713
838
|
|
|
714
839
|
def delete_page(self, page_id: str, *, purge: bool = False) -> None:
|
|
715
840
|
"""
|
|
@@ -761,16 +886,19 @@ class ConfluenceSession:
|
|
|
761
886
|
|
|
762
887
|
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
763
888
|
response = self.session.get(
|
|
764
|
-
url,
|
|
889
|
+
url,
|
|
890
|
+
params=query,
|
|
891
|
+
headers={
|
|
892
|
+
"Content-Type": "application/json",
|
|
893
|
+
"Accept": "application/json",
|
|
894
|
+
},
|
|
765
895
|
)
|
|
766
896
|
response.raise_for_status()
|
|
767
|
-
|
|
768
897
|
data = typing.cast(dict[str, JsonType], response.json())
|
|
769
|
-
results =
|
|
898
|
+
results = _json_to_object(list[ConfluencePageProperties], data["results"])
|
|
770
899
|
|
|
771
900
|
if len(results) == 1:
|
|
772
|
-
|
|
773
|
-
return typing.cast(str, result["id"])
|
|
901
|
+
return results[0].id
|
|
774
902
|
else:
|
|
775
903
|
return None
|
|
776
904
|
|
|
@@ -783,7 +911,7 @@ class ConfluenceSession:
|
|
|
783
911
|
"""
|
|
784
912
|
|
|
785
913
|
parent_page = self.get_page_properties(parent_id)
|
|
786
|
-
page_id = self.page_exists(title, space_id=parent_page.
|
|
914
|
+
page_id = self.page_exists(title, space_id=parent_page.spaceId)
|
|
787
915
|
|
|
788
916
|
if page_id is not None:
|
|
789
917
|
LOGGER.debug("Retrieving existing page: %s", page_id)
|
|
@@ -792,7 +920,7 @@ class ConfluenceSession:
|
|
|
792
920
|
LOGGER.debug("Creating new page with title: %s", title)
|
|
793
921
|
return self.create_page(parent_id, title, "")
|
|
794
922
|
|
|
795
|
-
def get_labels(self, page_id: str) -> list[
|
|
923
|
+
def get_labels(self, page_id: str) -> list[ConfluenceIdentifiedLabel]:
|
|
796
924
|
"""
|
|
797
925
|
Retrieves labels for a Confluence page.
|
|
798
926
|
|
|
@@ -800,13 +928,71 @@ class ConfluenceSession:
|
|
|
800
928
|
:returns: A list of page labels.
|
|
801
929
|
"""
|
|
802
930
|
|
|
803
|
-
items: list[ConfluenceLabel] = []
|
|
804
931
|
path = f"/pages/{page_id}/labels"
|
|
805
932
|
results = self._fetch(path)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
933
|
+
return _json_to_object(list[ConfluenceIdentifiedLabel], results)
|
|
934
|
+
|
|
935
|
+
def add_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
|
|
936
|
+
"""
|
|
937
|
+
Adds labels to a Confluence page.
|
|
938
|
+
|
|
939
|
+
:param page_id: The Confluence page ID.
|
|
940
|
+
:param labels: A list of page labels to add.
|
|
941
|
+
"""
|
|
942
|
+
|
|
943
|
+
path = f"/content/{page_id}/label"
|
|
944
|
+
|
|
945
|
+
url = self._build_url(ConfluenceVersion.VERSION_1, path)
|
|
946
|
+
response = self.session.post(
|
|
947
|
+
url,
|
|
948
|
+
data=json_dump_string(object_to_json(labels)),
|
|
949
|
+
headers={
|
|
950
|
+
"Content-Type": "application/json",
|
|
951
|
+
"Accept": "application/json",
|
|
952
|
+
},
|
|
953
|
+
)
|
|
954
|
+
if response.text:
|
|
955
|
+
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
956
|
+
response.raise_for_status()
|
|
957
|
+
|
|
958
|
+
def remove_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
|
|
959
|
+
"""
|
|
960
|
+
Removes labels from a Confluence page.
|
|
961
|
+
|
|
962
|
+
:param page_id: The Confluence page ID.
|
|
963
|
+
:param labels: A list of page labels to remove.
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
path = f"/content/{page_id}/label"
|
|
967
|
+
for label in labels:
|
|
968
|
+
query = {"name": label.name}
|
|
969
|
+
|
|
970
|
+
url = self._build_url(ConfluenceVersion.VERSION_1, path, query)
|
|
971
|
+
response = self.session.delete(url)
|
|
972
|
+
if response.text:
|
|
973
|
+
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
974
|
+
response.raise_for_status()
|
|
975
|
+
|
|
976
|
+
def update_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
|
|
977
|
+
"""
|
|
978
|
+
Assigns the specified labels to a Confluence page. Existing labels are removed.
|
|
979
|
+
|
|
980
|
+
:param page_id: The Confluence page ID.
|
|
981
|
+
:param labels: A list of page labels to assign.
|
|
982
|
+
"""
|
|
983
|
+
|
|
984
|
+
new_labels = set(labels)
|
|
985
|
+
old_labels = set(
|
|
986
|
+
ConfluenceLabel(name=label.name, prefix=label.prefix)
|
|
987
|
+
for label in self.get_labels(page_id)
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
add_labels = list(new_labels - old_labels)
|
|
991
|
+
remove_labels = list(old_labels - new_labels)
|
|
992
|
+
|
|
993
|
+
if add_labels:
|
|
994
|
+
add_labels.sort()
|
|
995
|
+
self.add_labels(page_id, add_labels)
|
|
996
|
+
if remove_labels:
|
|
997
|
+
remove_labels.sort()
|
|
998
|
+
self.remove_labels(page_id, remove_labels)
|