markdown-to-confluence 0.3.4__tar.gz → 0.3.5__tar.gz

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.
Files changed (35) hide show
  1. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/PKG-INFO +15 -9
  2. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/README.md +8 -2
  3. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/markdown_to_confluence.egg-info/PKG-INFO +15 -9
  4. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/markdown_to_confluence.egg-info/SOURCES.txt +3 -1
  5. markdown_to_confluence-0.3.5/markdown_to_confluence.egg-info/requires.txt +9 -0
  6. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/__init__.py +1 -1
  7. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/api.py +142 -23
  8. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/application.py +38 -33
  9. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/converter.py +34 -102
  10. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/local.py +16 -23
  11. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/matcher.py +54 -13
  12. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/mermaid.py +10 -4
  13. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/metadata.py +1 -1
  14. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/processor.py +7 -9
  15. markdown_to_confluence-0.3.5/md2conf/scanner.py +117 -0
  16. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/setup.cfg +6 -6
  17. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/tests/test_conversion.py +8 -8
  18. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/tests/test_matcher.py +6 -0
  19. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/tests/test_processor.py +1 -0
  20. markdown_to_confluence-0.3.5/tests/test_scanner.py +46 -0
  21. markdown_to_confluence-0.3.4/markdown_to_confluence.egg-info/requires.txt +0 -9
  22. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/LICENSE +0 -0
  23. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
  24. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/markdown_to_confluence.egg-info/entry_points.txt +0 -0
  25. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/markdown_to_confluence.egg-info/top_level.txt +0 -0
  26. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/markdown_to_confluence.egg-info/zip-safe +0 -0
  27. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/__main__.py +0 -0
  28. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/emoji.py +0 -0
  29. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/entities.dtd +0 -0
  30. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/properties.py +0 -0
  31. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/puppeteer-config.json +0 -0
  32. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/md2conf/py.typed +0 -0
  33. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/pyproject.toml +0 -0
  34. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/setup.py +0 -0
  35. {markdown_to_confluence-0.3.4 → markdown_to_confluence-0.3.5}/tests/test_mermaid.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,12 +21,12 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml>=5.3
25
- Requires-Dist: types-lxml>=2024.12.13
26
- Requires-Dist: markdown>=3.7
27
- Requires-Dist: types-markdown>=3.7
28
- Requires-Dist: pymdown-extensions>=10.14
29
- Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: lxml>=5.4
25
+ Requires-Dist: types-lxml>=2025.3.30
26
+ Requires-Dist: markdown>=3.8
27
+ Requires-Dist: types-markdown>=3.8
28
+ Requires-Dist: pymdown-extensions>=10.15
29
+ Requires-Dist: PyYAML>=6.0
30
30
  Requires-Dist: types-PyYAML>=6.0
31
31
  Requires-Dist: requests>=2.32
32
32
  Requires-Dist: types-requests>=2.32
@@ -198,20 +198,26 @@ root
198
198
  └── Mean vs. median
199
199
  ```
200
200
 
201
+ ### Lists and tables
202
+
203
+ If your Markdown lists or tables don't appear in Confluence as expected, verify that the list or table is delimited by a blank line both before and after, as per strict Markdown syntax. While some previewers accept a more lenient syntax (e.g. an itemized list immediately following a paragraph), *md2conf* uses [Python-Markdown](https://python-markdown.github.io/) internally to convert Markdown into XHTML, which expects the Markdown document to adhere to the stricter syntax.
204
+
201
205
  ### Publishing images
202
206
 
203
207
  Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
204
208
 
205
- Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
209
+ Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. (This seems to be a known issue in Confluence.) In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
206
210
 
207
211
  External images referenced with an absolute URL retain the original URL.
208
212
 
209
213
  ### Ignoring files
210
214
 
211
- Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
215
+ Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax (and constraints) of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
212
216
 
213
217
  Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
214
218
 
219
+ Relative paths to items in a nested directory are not supported. You must put `.mdignore` in the same directory where the items to be skipped reside.
220
+
215
221
  ### Page title
216
222
 
217
223
  *md2conf* makes a best-effort attempt at setting the Confluence wiki page title when it publishes a Markdown document the first time. The following are probed in this order:
@@ -164,20 +164,26 @@ root
164
164
  └── Mean vs. median
165
165
  ```
166
166
 
167
+ ### Lists and tables
168
+
169
+ If your Markdown lists or tables don't appear in Confluence as expected, verify that the list or table is delimited by a blank line both before and after, as per strict Markdown syntax. While some previewers accept a more lenient syntax (e.g. an itemized list immediately following a paragraph), *md2conf* uses [Python-Markdown](https://python-markdown.github.io/) internally to convert Markdown into XHTML, which expects the Markdown document to adhere to the stricter syntax.
170
+
167
171
  ### Publishing images
168
172
 
169
173
  Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
170
174
 
171
- Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
175
+ Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. (This seems to be a known issue in Confluence.) In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
172
176
 
173
177
  External images referenced with an absolute URL retain the original URL.
174
178
 
175
179
  ### Ignoring files
176
180
 
177
- Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
181
+ Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax (and constraints) of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
178
182
 
179
183
  Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
180
184
 
185
+ Relative paths to items in a nested directory are not supported. You must put `.mdignore` in the same directory where the items to be skipped reside.
186
+
181
187
  ### Page title
182
188
 
183
189
  *md2conf* makes a best-effort attempt at setting the Confluence wiki page title when it publishes a Markdown document the first time. The following are probed in this order:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,12 +21,12 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml>=5.3
25
- Requires-Dist: types-lxml>=2024.12.13
26
- Requires-Dist: markdown>=3.7
27
- Requires-Dist: types-markdown>=3.7
28
- Requires-Dist: pymdown-extensions>=10.14
29
- Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: lxml>=5.4
25
+ Requires-Dist: types-lxml>=2025.3.30
26
+ Requires-Dist: markdown>=3.8
27
+ Requires-Dist: types-markdown>=3.8
28
+ Requires-Dist: pymdown-extensions>=10.15
29
+ Requires-Dist: PyYAML>=6.0
30
30
  Requires-Dist: types-PyYAML>=6.0
31
31
  Requires-Dist: requests>=2.32
32
32
  Requires-Dist: types-requests>=2.32
@@ -198,20 +198,26 @@ root
198
198
  └── Mean vs. median
199
199
  ```
200
200
 
201
+ ### Lists and tables
202
+
203
+ If your Markdown lists or tables don't appear in Confluence as expected, verify that the list or table is delimited by a blank line both before and after, as per strict Markdown syntax. While some previewers accept a more lenient syntax (e.g. an itemized list immediately following a paragraph), *md2conf* uses [Python-Markdown](https://python-markdown.github.io/) internally to convert Markdown into XHTML, which expects the Markdown document to adhere to the stricter syntax.
204
+
201
205
  ### Publishing images
202
206
 
203
207
  Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
204
208
 
205
- Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
209
+ Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. (This seems to be a known issue in Confluence.) In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
206
210
 
207
211
  External images referenced with an absolute URL retain the original URL.
208
212
 
209
213
  ### Ignoring files
210
214
 
211
- Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
215
+ Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax (and constraints) of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
212
216
 
213
217
  Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
214
218
 
219
+ Relative paths to items in a nested directory are not supported. You must put `.mdignore` in the same directory where the items to be skipped reside.
220
+
215
221
  ### Page title
216
222
 
217
223
  *md2conf* makes a best-effort attempt at setting the Confluence wiki page title when it publishes a Markdown document the first time. The following are probed in this order:
@@ -25,7 +25,9 @@ md2conf/processor.py
25
25
  md2conf/properties.py
26
26
  md2conf/puppeteer-config.json
27
27
  md2conf/py.typed
28
+ md2conf/scanner.py
28
29
  tests/test_conversion.py
29
30
  tests/test_matcher.py
30
31
  tests/test_mermaid.py
31
- tests/test_processor.py
32
+ tests/test_processor.py
33
+ tests/test_scanner.py
@@ -0,0 +1,9 @@
1
+ lxml>=5.4
2
+ types-lxml>=2025.3.30
3
+ markdown>=3.8
4
+ types-markdown>=3.8
5
+ pymdown-extensions>=10.15
6
+ PyYAML>=6.0
7
+ types-PyYAML>=6.0
8
+ requests>=2.32
9
+ types-requests>=2.32
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.3.4"
8
+ __version__ = "0.3.5"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
@@ -43,13 +43,23 @@ JsonType = Union[
43
43
 
44
44
 
45
45
  class ConfluenceVersion(enum.Enum):
46
+ """
47
+ Confluence REST API version an HTTP request corresponds to.
48
+
49
+ For some operations, Confluence Cloud supports v2 endpoints exclusively. However, for other operations, only v1 endpoints are available via REST API.
50
+ Some versions of Confluence Server and Data Center, unfortunately, don't support v2 endpoints at all.
51
+
52
+ The principal use case for *md2conf* is Confluence Cloud. As such, *md2conf* uses v2 endpoints when available, and resorts to v1 endpoints only when
53
+ necessary.
54
+ """
55
+
46
56
  VERSION_1 = "rest/api"
47
57
  VERSION_2 = "api/v2"
48
58
 
49
59
 
50
60
  class ConfluencePageParentContentType(enum.Enum):
51
61
  """
52
- Content types that can be a parent to a Confluence page
62
+ Content types that can be a parent to a Confluence page.
53
63
  """
54
64
 
55
65
  PAGE = "page"
@@ -80,6 +90,15 @@ LOGGER = logging.getLogger(__name__)
80
90
 
81
91
  @dataclass(frozen=True)
82
92
  class ConfluenceAttachment:
93
+ """
94
+ Holds data for an object uploaded to Confluence as a page attachment.
95
+
96
+ :param id: Unique ID for the attachment.
97
+ :param media_type: MIME type for the attachment.
98
+ :param file_size: Size in bytes.
99
+ :param comment: Description for the attachment.
100
+ """
101
+
83
102
  id: str
84
103
  media_type: str
85
104
  file_size: int
@@ -87,7 +106,18 @@ class ConfluenceAttachment:
87
106
 
88
107
 
89
108
  @dataclass(frozen=True)
90
- class ConfluencePageMetadata:
109
+ class ConfluencePageProperties:
110
+ """
111
+ Holds Confluence page properties used for page synchronization.
112
+
113
+ :param id: Confluence page ID.
114
+ :param space_id: Confluence space ID.
115
+ :param parent_id: Confluence page ID of the immediate parent.
116
+ :param parent_type: Identifies the content type of the parent.
117
+ :param title: Page title.
118
+ :param version: Page version. Incremented when the page is updated.
119
+ """
120
+
91
121
  id: str
92
122
  space_id: str
93
123
  parent_id: str
@@ -97,11 +127,36 @@ class ConfluencePageMetadata:
97
127
 
98
128
 
99
129
  @dataclass(frozen=True)
100
- class ConfluencePage(ConfluencePageMetadata):
130
+ class ConfluencePage(ConfluencePageProperties):
131
+ """
132
+ Holds Confluence page data used for page synchronization.
133
+
134
+ :param content: Page content in Confluence Storage Format.
135
+ """
136
+
101
137
  content: str
102
138
 
103
139
 
140
+ @dataclass(frozen=True)
141
+ class ConfluenceLabel:
142
+ """
143
+ Holds information about a single label.
144
+
145
+ :param id: ID of the label.
146
+ :param name: Name of the label.
147
+ :param prefix: Prefix of the label.
148
+ """
149
+
150
+ id: str
151
+ name: str
152
+ prefix: str
153
+
154
+
104
155
  class ConfluenceAPI:
156
+ """
157
+ Represents an active connection to a Confluence server.
158
+ """
159
+
105
160
  properties: ConfluenceConnectionProperties
106
161
  session: Optional["ConfluenceSession"] = None
107
162
 
@@ -195,7 +250,7 @@ class ConfluenceSession:
195
250
  path: str,
196
251
  query: Optional[dict[str, str]] = None,
197
252
  ) -> JsonType:
198
- "Execute an HTTP request via Confluence API."
253
+ "Executes an HTTP request via Confluence API."
199
254
 
200
255
  url = self._build_url(version, path, query)
201
256
  response = self.session.get(url)
@@ -204,7 +259,33 @@ class ConfluenceSession:
204
259
  response.raise_for_status()
205
260
  return response.json()
206
261
 
207
- def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
262
+ def _fetch(
263
+ self, path: str, query: Optional[dict[str, str]] = None
264
+ ) -> list[JsonType]:
265
+ "Retrieves all results of a REST API v2 paginated result-set."
266
+
267
+ items: list[JsonType] = []
268
+ url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
269
+ while True:
270
+ response = self.session.get(url)
271
+ response.raise_for_status()
272
+
273
+ payload = typing.cast(dict[str, JsonType], response.json())
274
+ results = typing.cast(list[JsonType], payload["results"])
275
+ items.extend(results)
276
+
277
+ links = typing.cast(dict[str, JsonType], payload.get("_links", {}))
278
+ link = typing.cast(str, links.get("next", ""))
279
+ if link:
280
+ url = f"https://{self.site.domain}{link}"
281
+ else:
282
+ break
283
+
284
+ return items
285
+
286
+ def _save(self, version: ConfluenceVersion, path: str, data: JsonType) -> None:
287
+ "Persists data via Confluence REST API."
288
+
208
289
  url = self._build_url(version, path)
209
290
  response = self.session.put(
210
291
  url,
@@ -263,7 +344,7 @@ class ConfluenceSession:
263
344
  self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
264
345
  ) -> Optional[str]:
265
346
  """
266
- Coalesce a space ID or space key into a space ID, accounting for site default.
347
+ Coalesces a space ID or space key into a space ID, accounting for site default.
267
348
 
268
349
  :param space_id: A Confluence space ID.
269
350
  :param space_key: A Confluence space key.
@@ -285,6 +366,10 @@ class ConfluenceSession:
285
366
  def get_attachment_by_name(
286
367
  self, page_id: str, filename: str
287
368
  ) -> ConfluenceAttachment:
369
+ """
370
+ Retrieves a Confluence page attachment by an unprefixed file name.
371
+ """
372
+
288
373
  path = f"/pages/{page_id}/attachments"
289
374
  query = {"filename": filename}
290
375
  data = typing.cast(
@@ -313,6 +398,18 @@ class ConfluenceSession:
313
398
  comment: Optional[str] = None,
314
399
  force: bool = False,
315
400
  ) -> None:
401
+ """
402
+ Uploads a new attachment to a Confluence page.
403
+
404
+ :param page_id: Confluence page ID.
405
+ :param attachment_name: Unprefixed name unique to the page.
406
+ :param attachment_path: Path to the file to upload as an attachment.
407
+ :param raw_data: Raw data to upload as an attachment.
408
+ :param content_type: Attachment MIME type.
409
+ :param comment: Attachment description.
410
+ :param force: Overwrite an existing attachment even if there seem to be no changes.
411
+ """
412
+
316
413
  if attachment_path is None and raw_data is None:
317
414
  raise ArgumentError("required: `attachment_path` or `raw_data`")
318
415
 
@@ -409,7 +506,7 @@ class ConfluenceSession:
409
506
  ) -> None:
410
507
  id = attachment_id.removeprefix("att")
411
508
  path = f"/content/{page_id}/child/attachment/{id}"
412
- data = {
509
+ data: JsonType = {
413
510
  "id": attachment_id,
414
511
  "type": "attachment",
415
512
  "status": "current",
@@ -428,10 +525,11 @@ class ConfluenceSession:
428
525
  space_key: Optional[str] = None,
429
526
  ) -> str:
430
527
  """
431
- Look up a Confluence wiki page ID by title.
528
+ Looks up a Confluence wiki page ID by title.
432
529
 
433
530
  :param title: The page title.
434
- :param space_key: The Confluence space key (unless the default space is to be used).
531
+ :param space_id: The Confluence space ID (unless the default space is to be used). Exclusive with space key.
532
+ :param space_key: The Confluence space key (unless the default space is to be used). Exclusive with space ID.
435
533
  :returns: Confluence page ID.
436
534
  """
437
535
 
@@ -457,7 +555,7 @@ class ConfluenceSession:
457
555
 
458
556
  def get_page(self, page_id: str) -> ConfluencePage:
459
557
  """
460
- Retrieve Confluence wiki page details and content.
558
+ Retrieves Confluence wiki page details and content.
461
559
 
462
560
  :param page_id: The Confluence page ID.
463
561
  :returns: Confluence page info and content.
@@ -486,9 +584,9 @@ class ConfluenceSession:
486
584
  )
487
585
 
488
586
  @functools.cache
489
- def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata:
587
+ def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
490
588
  """
491
- Retrieve Confluence wiki page details.
589
+ Retrieves Confluence wiki page details.
492
590
 
493
591
  :param page_id: The Confluence page ID.
494
592
  :returns: Confluence page info.
@@ -499,7 +597,7 @@ class ConfluenceSession:
499
597
  data = typing.cast(dict[str, JsonType], payload)
500
598
  version = typing.cast(dict[str, JsonType], data["version"])
501
599
 
502
- return ConfluencePageMetadata(
600
+ return ConfluencePageProperties(
503
601
  id=page_id,
504
602
  space_id=typing.cast(str, data["spaceId"]),
505
603
  parent_id=typing.cast(str, data["parentId"]),
@@ -514,7 +612,7 @@ class ConfluenceSession:
514
612
 
515
613
  def get_page_version(self, page_id: str) -> int:
516
614
  """
517
- Retrieve a Confluence wiki page version.
615
+ Retrieves a Confluence wiki page version.
518
616
 
519
617
  :param page_id: The Confluence page ID.
520
618
  :returns: Confluence page version.
@@ -534,7 +632,7 @@ class ConfluenceSession:
534
632
  title: Optional[str] = None,
535
633
  ) -> None:
536
634
  """
537
- Update a page via the Confluence API.
635
+ Updates a page via the Confluence API.
538
636
 
539
637
  :param page_id: The Confluence page ID.
540
638
  :param new_content: Confluence Storage Format XHTML.
@@ -553,7 +651,7 @@ class ConfluenceSession:
553
651
  LOGGER.warning(exc)
554
652
 
555
653
  path = f"/pages/{page_id}"
556
- data = {
654
+ data: JsonType = {
557
655
  "id": page_id,
558
656
  "status": "current",
559
657
  "title": new_title,
@@ -571,10 +669,10 @@ class ConfluenceSession:
571
669
  new_content: str,
572
670
  ) -> ConfluencePage:
573
671
  """
574
- Create a new page via Confluence API.
672
+ Creates a new page via Confluence API.
575
673
  """
576
674
 
577
- parent_page = self.get_page_metadata(parent_id)
675
+ parent_page = self.get_page_properties(parent_id)
578
676
  path = "/pages/"
579
677
  query = {
580
678
  "spaceId": parent_page.space_id,
@@ -615,10 +713,10 @@ class ConfluenceSession:
615
713
 
616
714
  def delete_page(self, page_id: str, *, purge: bool = False) -> None:
617
715
  """
618
- Delete a page via Confluence API.
716
+ Deletes a page via Confluence API.
619
717
 
620
718
  :param page_id: The Confluence page ID.
621
- :param purge: True to completely purge the page, False to move to trash only.
719
+ :param purge: `True` to completely purge the page, `False` to move to trash only.
622
720
  """
623
721
 
624
722
  path = f"/pages/{page_id}"
@@ -645,10 +743,12 @@ class ConfluenceSession:
645
743
  space_key: Optional[str] = None,
646
744
  ) -> Optional[str]:
647
745
  """
648
- Check if a Confluence page exists with the given title.
746
+ Checks if a Confluence page exists with the given title.
649
747
 
650
748
  :param title: Page title. Pages in the same Confluence space must have a unique title.
651
749
  :param space_key: Identifies the Confluence space.
750
+
751
+ :returns: Confluence page ID of a matching page (if found), or `None`.
652
752
  """
653
753
 
654
754
  space_id = self.get_space_id(space_id=space_id, space_key=space_key)
@@ -676,13 +776,13 @@ class ConfluenceSession:
676
776
 
677
777
  def get_or_create_page(self, title: str, parent_id: str) -> ConfluencePage:
678
778
  """
679
- Find a page with the given title, or create a new page if no such page exists.
779
+ Finds a page with the given title, or creates a new page if no such page exists.
680
780
 
681
781
  :param title: Page title. Pages in the same Confluence space must have a unique title.
682
782
  :param parent_id: Identifies the parent page for a new child page.
683
783
  """
684
784
 
685
- parent_page = self.get_page_metadata(parent_id)
785
+ parent_page = self.get_page_properties(parent_id)
686
786
  page_id = self.page_exists(title, space_id=parent_page.space_id)
687
787
 
688
788
  if page_id is not None:
@@ -691,3 +791,22 @@ class ConfluenceSession:
691
791
  else:
692
792
  LOGGER.debug("Creating new page with title: %s", title)
693
793
  return self.create_page(parent_id, title, "")
794
+
795
+ def get_labels(self, page_id: str) -> list[ConfluenceLabel]:
796
+ """
797
+ Retrieves labels for a Confluence page.
798
+
799
+ :param page_id: The Confluence page ID.
800
+ :returns: A list of page labels.
801
+ """
802
+
803
+ items: list[ConfluenceLabel] = []
804
+ path = f"/pages/{page_id}/labels"
805
+ results = self._fetch(path)
806
+ for r in results:
807
+ result = typing.cast(dict[str, JsonType], r)
808
+ id = typing.cast(str, result["id"])
809
+ name = typing.cast(str, result["name"])
810
+ prefix = typing.cast(str, result["prefix"])
811
+ items.append(ConfluenceLabel(id, name, prefix))
812
+ return items
@@ -17,12 +17,11 @@ from .converter import (
17
17
  ConfluenceDocumentOptions,
18
18
  ConfluencePageID,
19
19
  attachment_name,
20
- extract_frontmatter_title,
21
- extract_qualified_id,
22
20
  )
23
21
  from .metadata import ConfluencePageMetadata
24
22
  from .processor import Converter, Processor, ProcessorFactory
25
23
  from .properties import PageError
24
+ from .scanner import Scanner
26
25
 
27
26
  LOGGER = logging.getLogger(__name__)
28
27
 
@@ -49,56 +48,43 @@ class SynchronizingProcessor(Processor):
49
48
  self.api = api
50
49
 
51
50
  def _get_or_create_page(
52
- self,
53
- absolute_path: Path,
54
- parent_id: Optional[ConfluencePageID],
55
- *,
56
- title: Optional[str] = None,
51
+ self, absolute_path: Path, parent_id: Optional[ConfluencePageID]
57
52
  ) -> ConfluencePageMetadata:
58
53
  """
59
54
  Creates a new Confluence page if no page is linked in the Markdown document.
60
55
  """
61
56
 
62
57
  # parse file
63
- with open(absolute_path, "r", encoding="utf-8") as f:
64
- text = f.read()
65
-
66
- qualified_id, text = extract_qualified_id(text)
58
+ document = Scanner().read(absolute_path)
67
59
 
68
60
  overwrite = False
69
- if qualified_id is None:
61
+ if document.page_id is None:
70
62
  # create new Confluence page
71
63
  if parent_id is None:
72
64
  raise PageError(
73
65
  f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
74
66
  )
75
67
 
76
- # assign title from front-matter if present
77
- if title is None:
78
- title, _ = extract_frontmatter_title(text)
79
-
80
68
  # use file name (without extension) and path hash if no title is supplied
81
- if title is None:
69
+ if document.title is not None:
70
+ title = document.title
71
+ else:
82
72
  overwrite = True
83
73
  relative_path = absolute_path.relative_to(self.root_dir)
84
74
  hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
85
75
  digest = "".join(f"{c:x}" for c in hash.digest())
86
76
  title = f"{absolute_path.stem} [{digest}]"
87
77
 
88
- confluence_page = self._create_page(absolute_path, text, title, parent_id)
78
+ confluence_page = self._create_page(
79
+ absolute_path, document.text, title, parent_id
80
+ )
89
81
  else:
90
82
  # look up existing Confluence page
91
- confluence_page = self.api.get_page(qualified_id.page_id)
92
-
93
- space_key = (
94
- self.api.space_id_to_key(confluence_page.space_id)
95
- if confluence_page.space_id
96
- else self.site.space_key
97
- )
83
+ confluence_page = self.api.get_page(document.page_id)
98
84
 
99
85
  return ConfluencePageMetadata(
100
86
  page_id=confluence_page.id,
101
- space_key=space_key,
87
+ space_key=self.api.space_id_to_key(confluence_page.space_id),
102
88
  title=confluence_page.title,
103
89
  overwrite=overwrite,
104
90
  )
@@ -123,7 +109,9 @@ class SynchronizingProcessor(Processor):
123
109
  )
124
110
  return confluence_page
125
111
 
126
- def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
112
+ def _save_document(
113
+ self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
114
+ ) -> None:
127
115
  """
128
116
  Saves a new version of a Confluence document.
129
117
 
@@ -133,25 +121,40 @@ class SynchronizingProcessor(Processor):
133
121
  base_path = path.parent
134
122
  for image in document.images:
135
123
  self.api.upload_attachment(
136
- document.id.page_id,
124
+ page_id.page_id,
137
125
  attachment_name(image),
138
126
  attachment_path=base_path / image,
139
127
  )
140
128
 
141
129
  for name, data in document.embedded_images.items():
142
130
  self.api.upload_attachment(
143
- document.id.page_id,
131
+ page_id.page_id,
144
132
  name,
145
133
  raw_data=data,
146
134
  )
147
135
 
148
136
  content = document.xhtml()
137
+ LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
149
138
 
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
139
+ title = None
140
+ if document.title is not None:
141
+ meta = self.page_metadata[path]
152
142
 
153
- LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
154
- self.api.update_page(document.id.page_id, content, title=title)
143
+ # update title only for pages with randomly assigned title
144
+ if meta.overwrite:
145
+ conflicting_page_id = self.api.page_exists(
146
+ document.title, space_id=self.api.space_key_to_id(meta.space_key)
147
+ )
148
+ if conflicting_page_id is None:
149
+ title = document.title
150
+ else:
151
+ LOGGER.info(
152
+ "Document title of %s conflicts with Confluence page title of %s",
153
+ path,
154
+ conflicting_page_id,
155
+ )
156
+
157
+ self.api.update_page(page_id.page_id, content, title=title)
155
158
 
156
159
  def _update_markdown(
157
160
  self,
@@ -200,6 +203,8 @@ class SynchronizingProcessorFactory(ProcessorFactory):
200
203
  class Application(Converter):
201
204
  """
202
205
  The entry point for Markdown to Confluence conversion.
206
+
207
+ This is the class instantiated by the command-line application.
203
208
  """
204
209
 
205
210
  def __init__(