markdown-to-confluence 0.5.2__py3-none-any.whl → 0.5.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/METADATA +258 -157
  2. markdown_to_confluence-0.5.4.dist-info/RECORD +55 -0
  3. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/licenses/LICENSE +1 -1
  4. md2conf/__init__.py +2 -2
  5. md2conf/__main__.py +83 -44
  6. md2conf/api.py +30 -10
  7. md2conf/attachment.py +72 -0
  8. md2conf/coalesce.py +43 -0
  9. md2conf/collection.py +1 -1
  10. md2conf/{extra.py → compatibility.py} +1 -1
  11. md2conf/converter.py +240 -657
  12. md2conf/csf.py +13 -11
  13. md2conf/drawio/__init__.py +0 -0
  14. md2conf/drawio/extension.py +116 -0
  15. md2conf/{drawio.py → drawio/render.py} +1 -1
  16. md2conf/emoticon.py +3 -3
  17. md2conf/environment.py +2 -2
  18. md2conf/extension.py +82 -0
  19. md2conf/external.py +66 -0
  20. md2conf/formatting.py +135 -0
  21. md2conf/frontmatter.py +70 -0
  22. md2conf/image.py +128 -0
  23. md2conf/latex.py +4 -183
  24. md2conf/local.py +8 -8
  25. md2conf/markdown.py +1 -1
  26. md2conf/matcher.py +1 -1
  27. md2conf/mermaid/__init__.py +0 -0
  28. md2conf/mermaid/config.py +20 -0
  29. md2conf/mermaid/extension.py +109 -0
  30. md2conf/{mermaid.py → mermaid/render.py} +10 -38
  31. md2conf/mermaid/scanner.py +55 -0
  32. md2conf/metadata.py +1 -1
  33. md2conf/{domain.py → options.py} +75 -16
  34. md2conf/plantuml/__init__.py +0 -0
  35. md2conf/plantuml/config.py +20 -0
  36. md2conf/plantuml/extension.py +158 -0
  37. md2conf/plantuml/render.py +138 -0
  38. md2conf/plantuml/scanner.py +56 -0
  39. md2conf/png.py +206 -0
  40. md2conf/processor.py +55 -13
  41. md2conf/publisher.py +127 -39
  42. md2conf/scanner.py +38 -129
  43. md2conf/serializer.py +2 -2
  44. md2conf/svg.py +144 -103
  45. md2conf/text.py +1 -1
  46. md2conf/toc.py +73 -1
  47. md2conf/uri.py +1 -1
  48. md2conf/xml.py +1 -1
  49. markdown_to_confluence-0.5.2.dist-info/RECORD +0 -36
  50. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/WHEEL +0 -0
  51. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/entry_points.txt +0 -0
  52. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/top_level.txt +0 -0
  53. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/zip-safe +0 -0
  54. /md2conf/{puppeteer-config.json → mermaid/puppeteer-config.json} +0 -0
@@ -0,0 +1,55 @@
1
+ markdown_to_confluence-0.5.4.dist-info/licenses/LICENSE,sha256=SEEBf2BMI1LUHnDvyHnV6L12A6zTAOQcsyMvaawAXWo,1077
2
+ md2conf/__init__.py,sha256=0xq0z3v7oQaJxJwZRPOd-Z4zgOOCnzgDJYa9nytHTD0,402
3
+ md2conf/__main__.py,sha256=BPUZd0uzCsAOH3Y5o7ydvV7Z77jSI8fHfo4AZPyYj2c,14639
4
+ md2conf/api.py,sha256=3TlUmiDU31dfL8raMwv2wQWV_IvVq6fu8j86cjKTz1A,42780
5
+ md2conf/attachment.py,sha256=Nc3qGDENWBnsI6OVwMLXnk0EyEITpvov9MluDFD90ZI,1689
6
+ md2conf/coalesce.py,sha256=YHnqFwow5wCj6OQ3oosig01D2lxWusAScMF4HAUO2-g,1305
7
+ md2conf/collection.py,sha256=ukN74VCa4HaGSh6tLXpLd0j_UNPywcnKI0X7usgdSCo,824
8
+ md2conf/compatibility.py,sha256=4ZNN6VLqxSbI1kowdsPproGZqwxBISys4Z22vBfe6Z8,687
9
+ md2conf/converter.py,sha256=7eP4sEPgmyjiD0PO3jjyS5TcDY3OSW9hYsriLd9rbek,63201
10
+ md2conf/csf.py,sha256=6H9G-5cZyyWMJr0tFskPNiWdQ2Ehq-V8EhlvvxhukWY,6582
11
+ md2conf/emoticon.py,sha256=0g4rkx3d58xU4nnLak5ms7i0FSDnq0WJrLVFRgGyLC8,542
12
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
13
+ md2conf/environment.py,sha256=TfNEz3Pyw9qe7f8i7e_kph16c09fhZ4cLNZZzIjmI18,3892
14
+ md2conf/extension.py,sha256=_IBf_yhYb6luQM3A-vAAtCpjHay33kE4Au_SGuC3kow,2274
15
+ md2conf/external.py,sha256=uY1G7bdqEMJW66vOvKsh5CS4oHY-YA7h2VVuaSdaqBo,2366
16
+ md2conf/formatting.py,sha256=ygL59VgpioX069axEX-7XjKs0sUjTfIZiBE5fWmITxc,4557
17
+ md2conf/frontmatter.py,sha256=iWtn_oXoLQxvCsdI3OXs1ylWGmB-gc7mMLpSGg113i4,1888
18
+ md2conf/image.py,sha256=YrtcE5KhzcbjiT-oQEkk--yKSiRSPlDUtMpekoepIdo,5289
19
+ md2conf/latex.py,sha256=haZKkUxSEcPj3fVmiIVZAwgszqNqGLk1GQ7i8KGHpo0,2226
20
+ md2conf/local.py,sha256=eY3WpY-lNzLZeAfxX1ACVEhuzz0HDYX_sNQogJfkqcM,3673
21
+ md2conf/markdown.py,sha256=4Km-AbQH04nDgPF0ijo-Ld7o8jTPXzENIMn7P1qIk0o,3148
22
+ md2conf/matcher.py,sha256=Xg4YSb87iPkCzhKuKytBut6NOkEab3IM-AjzXbwy64U,6774
23
+ md2conf/metadata.py,sha256=NOjbCIrwLgTIIeNgmo7w5JXuT-pxOXBGSg-irfdpokk,976
24
+ md2conf/options.py,sha256=DLxnQBhDmDJgfEDSYyMChJi_krS1nsquOHBKg82aGrY,4500
25
+ md2conf/png.py,sha256=GU3-0dG6HqwGjedJVUciaIdA-6CdPTy_clsOQGr6dGE,6251
26
+ md2conf/processor.py,sha256=xVLpvKg2FEO0tWsHQ8sm7YpimQepbZ07W0_yUzcvl6c,11116
27
+ md2conf/publisher.py,sha256=xRIig53b4-DLncL07XBgLN3ecmTsWwNEK3ckjyhqfU8,11574
28
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
+ md2conf/scanner.py,sha256=bupKNe47DRc8MyMLzYfgtzyVHV9osJSgnr7KCnKsMuM,3888
30
+ md2conf/serializer.py,sha256=W4_yLJfT3vLw0PUg88lpUEnvn64CjaX3ZaKgIrwcxfw,1786
31
+ md2conf/svg.py,sha256=fjr8sWe-tqdAKaIq2bsR9qPrhnCXUmoVRtezHZa86cg,11558
32
+ md2conf/text.py,sha256=cnYV_JQp_v91LbQHo3qvxcEuhIdaPjCjkmLOKINcNv4,1736
33
+ md2conf/toc.py,sha256=aJEH3fIzDr2RufxFbHJ8maEpezp8uXI_uw90k3-KNkA,4585
34
+ md2conf/uri.py,sha256=my0deyR5SlppJrYCbXF1Zz94QA1JT-HTWe9pKw7AJ_A,1158
35
+ md2conf/xml.py,sha256=uaaUDs0hfluNX74dfkY_Dxu1KmeNDGogpGRGpUVEfE4,5526
36
+ md2conf/drawio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
+ md2conf/drawio/extension.py,sha256=HHLUriTfg82VCfOEyzU-6j2IM9rxR3I1UdSDdujWHgU,4409
38
+ md2conf/drawio/render.py,sha256=veSu5gjm5ggLnmaH7uvH9qNeOygBJpqhSKK_LJs0QTk,8581
39
+ md2conf/mermaid/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
+ md2conf/mermaid/config.py,sha256=5Dec2QcdB_GtnuXIW6nhJK8J5caduNZU1oz1mcmmb44,376
41
+ md2conf/mermaid/extension.py,sha256=1drXVM_KbS00dcjSCRru0wwbil4zq3aR81dHMhfe7zA,4021
42
+ md2conf/mermaid/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
43
+ md2conf/mermaid/render.py,sha256=zO6M5UWSKiezoxPojD8iwFnwrFEDw_P6liQi-C3LQgw,1817
44
+ md2conf/mermaid/scanner.py,sha256=oIpaNxiZBNcmggnjlyYGcIVOXcYQWjf1lEVdyIwE4xE,1379
45
+ md2conf/plantuml/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
+ md2conf/plantuml/config.py,sha256=j0ONhkzmAPagh00ltamTKlVEvXa6R284We9pDxRy-5U,378
47
+ md2conf/plantuml/extension.py,sha256=EQ-2O4d2cWBGcIHcFFXgaCNrfi357hS6IE_PsvwJ8_k,6256
48
+ md2conf/plantuml/render.py,sha256=Lf1It2KxHPKNGM1rhIDg9zdC3iqhRNCduByqa0_k_qw,3725
49
+ md2conf/plantuml/scanner.py,sha256=Oso6VbHVuMaPMKMazQc_bf4hhOT5WeJN5WiVPM8peyM,1347
50
+ markdown_to_confluence-0.5.4.dist-info/METADATA,sha256=bz_rRHiqV-EYCMcXCE9NMlUc8bk28SbFz9KO43YuW0E,45324
51
+ markdown_to_confluence-0.5.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
52
+ markdown_to_confluence-0.5.4.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
53
+ markdown_to_confluence-0.5.4.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
54
+ markdown_to_confluence-0.5.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
55
+ markdown_to_confluence-0.5.4.dist-info/RECORD,,
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022-2025 Levente Hunyadi
3
+ Copyright (c) 2022-2026 Levente Hunyadi
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
md2conf/__init__.py CHANGED
@@ -5,9 +5,9 @@ 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.5.2"
8
+ __version__ = "0.5.4"
9
9
  __author__ = "Levente Hunyadi"
10
- __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
10
+ __copyright__ = "Copyright 2022-2026, Levente Hunyadi"
11
11
  __license__ = "MIT"
12
12
  __maintainer__ = "Levente Hunyadi"
13
13
  __status__ = "Production"
md2conf/__main__.py CHANGED
@@ -4,7 +4,7 @@ Publish Markdown files to Confluence wiki.
4
4
  Parses Markdown files, converts Markdown content into the Confluence Storage Format (XHTML), and invokes
5
5
  Confluence API endpoints to upload images and content.
6
6
 
7
- Copyright 2022-2025, Levente Hunyadi
7
+ Copyright 2022-2026, Levente Hunyadi
8
8
 
9
9
  :see: https://github.com/hunyadi/md2conf
10
10
  """
@@ -16,17 +16,22 @@ import sys
16
16
  import typing
17
17
  from io import StringIO
18
18
  from pathlib import Path
19
+ from types import TracebackType
19
20
  from typing import Any, Iterable, Literal, Sequence
20
21
 
22
+ from requests.exceptions import HTTPError, JSONDecodeError
23
+
21
24
  from . import __version__
22
- from .domain import ConfluenceDocumentOptions, ConfluencePageID
23
- from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
24
- from .extra import override
25
+ from .compatibility import override
26
+ from .environment import ArgumentError, ConfluenceSiteProperties, ConnectionProperties
25
27
  from .metadata import ConfluenceSiteMetadata
28
+ from .options import ConfluencePageID, ConverterOptions, DocumentOptions, ImageLayoutOptions, LayoutOptions
29
+
30
+ LOGGER = logging.getLogger(__name__)
26
31
 
27
32
 
28
33
  class Arguments(argparse.Namespace):
29
- mdpath: Path
34
+ mdpath: list[Path]
30
35
  domain: str | None
31
36
  path: str | None
32
37
  api_url: str | None
@@ -41,9 +46,11 @@ class Arguments(argparse.Namespace):
41
46
  skip_title_heading: bool
42
47
  title_prefix: str | None
43
48
  generated_by: str | None
49
+ skip_update: bool
44
50
  prefer_raster: bool
45
51
  render_drawio: bool
46
52
  render_mermaid: bool
53
+ render_plantuml: bool
47
54
  render_latex: bool
48
55
  diagram_output_format: Literal["png", "svg"]
49
56
  local: bool
@@ -99,7 +106,7 @@ def get_parser() -> argparse.ArgumentParser:
99
106
  parser = argparse.ArgumentParser(formatter_class=PositionalOnlyHelpFormatter)
100
107
  parser.prog = os.path.basename(os.path.dirname(__file__))
101
108
  parser.add_argument("--version", action="version", version=__version__)
102
- parser.add_argument("mdpath", help="Path to Markdown file or directory to convert and publish.")
109
+ parser.add_argument("mdpath", type=Path, nargs="+", help="Path to Markdown file or directory to convert and publish.")
103
110
  parser.add_argument("-d", "--domain", help="Confluence organization domain.")
104
111
  parser.add_argument("-p", "--path", help="Base path for Confluence (default: '/wiki/').")
105
112
  parser.add_argument(
@@ -165,6 +172,12 @@ def get_parser() -> argparse.ArgumentParser:
165
172
  const=None,
166
173
  help="Do not add 'generated by a tool' prompt to pages.",
167
174
  )
175
+ parser.add_argument(
176
+ "--skip-update",
177
+ action="store_true",
178
+ default=False,
179
+ help="Skip saving Confluence page ID in Markdown files.",
180
+ )
168
181
  parser.add_argument(
169
182
  "--render-drawio",
170
183
  dest="render_drawio",
@@ -191,6 +204,19 @@ def get_parser() -> argparse.ArgumentParser:
191
204
  action="store_false",
192
205
  help="Upload Mermaid diagram sources as Confluence page attachments. (Marketplace app required to display.)",
193
206
  )
207
+ parser.add_argument(
208
+ "--render-plantuml",
209
+ dest="render_plantuml",
210
+ action="store_true",
211
+ default=True,
212
+ help="Render PlantUML diagrams as image files. (Installed utility required to convert.)",
213
+ )
214
+ parser.add_argument(
215
+ "--no-render-plantuml",
216
+ dest="render_plantuml",
217
+ action="store_false",
218
+ help="Upload PlantUML diagram sources as Confluence page attachments. (Marketplace app required to display.)",
219
+ )
194
220
  parser.add_argument(
195
221
  "--render-latex",
196
222
  dest="render_latex",
@@ -310,35 +336,61 @@ def get_help() -> str:
310
336
  return buf.getvalue()
311
337
 
312
338
 
339
+ def _exception_hook(exc_type: type[BaseException], exc_value: BaseException, traceback: TracebackType | None) -> None:
340
+ LOGGER.exception("Exception raised: %s", exc_type.__name__, exc_info=exc_value)
341
+ ex: BaseException | None = exc_value
342
+ while ex is not None:
343
+ print(f"\033[95m{ex.__class__.__name__}\033[0m: {ex}")
344
+
345
+ if isinstance(ex, HTTPError):
346
+ # print details for a response with JSON body
347
+ if ex.response is not None:
348
+ try:
349
+ LOGGER.error(ex.response.json())
350
+ except JSONDecodeError:
351
+ pass
352
+
353
+ ex = ex.__cause__
354
+
355
+
356
+ sys.excepthook = _exception_hook
357
+
358
+
313
359
  def main() -> None:
314
360
  parser = get_parser()
315
361
  args = Arguments()
316
362
  parser.parse_args(namespace=args)
317
363
 
318
- args.mdpath = Path(args.mdpath)
319
-
320
364
  logging.basicConfig(
321
365
  level=getattr(logging, args.loglevel.upper(), logging.INFO),
322
366
  format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
323
367
  )
324
368
 
325
- options = ConfluenceDocumentOptions(
326
- heading_anchors=args.heading_anchors,
327
- ignore_invalid_url=args.ignore_invalid_url,
328
- skip_title_heading=args.skip_title_heading,
329
- title_prefix=args.title_prefix,
330
- generated_by=args.generated_by,
369
+ options = DocumentOptions(
331
370
  root_page_id=ConfluencePageID(args.root_page) if args.root_page else None,
332
371
  keep_hierarchy=args.keep_hierarchy,
333
- prefer_raster=args.prefer_raster,
334
- render_drawio=args.render_drawio,
335
- render_mermaid=args.render_mermaid,
336
- render_latex=args.render_latex,
337
- diagram_output_format=args.diagram_output_format,
338
- webui_links=args.webui_links,
339
- alignment=args.alignment,
340
- max_image_width=args.max_image_width,
341
- use_panel=args.use_panel,
372
+ title_prefix=args.title_prefix,
373
+ generated_by=args.generated_by,
374
+ skip_update=args.skip_update,
375
+ converter=ConverterOptions(
376
+ heading_anchors=args.heading_anchors,
377
+ ignore_invalid_url=args.ignore_invalid_url,
378
+ skip_title_heading=args.skip_title_heading,
379
+ prefer_raster=args.prefer_raster,
380
+ render_drawio=args.render_drawio,
381
+ render_mermaid=args.render_mermaid,
382
+ render_plantuml=args.render_plantuml,
383
+ render_latex=args.render_latex,
384
+ diagram_output_format=args.diagram_output_format,
385
+ webui_links=args.webui_links,
386
+ use_panel=args.use_panel,
387
+ layout=LayoutOptions(
388
+ image=ImageLayoutOptions(
389
+ alignment=args.alignment,
390
+ max_width=args.max_image_width,
391
+ ),
392
+ ),
393
+ ),
342
394
  )
343
395
  if args.local:
344
396
  from .local import LocalConverter
@@ -356,15 +408,15 @@ def main() -> None:
356
408
  base_path=site_properties.base_path,
357
409
  space_key=site_properties.space_key,
358
410
  )
359
- LocalConverter(options, site_metadata).process(args.mdpath)
411
+ converter = LocalConverter(options, site_metadata)
412
+ for item in args.mdpath:
413
+ converter.process(item)
360
414
  else:
361
- from requests import HTTPError, JSONDecodeError
362
-
363
415
  from .api import ConfluenceAPI
364
416
  from .publisher import Publisher
365
417
 
366
418
  try:
367
- properties = ConfluenceConnectionProperties(
419
+ properties = ConnectionProperties(
368
420
  api_url=args.api_url,
369
421
  domain=args.domain,
370
422
  base_path=args.path,
@@ -375,23 +427,10 @@ def main() -> None:
375
427
  )
376
428
  except ArgumentError as e:
377
429
  parser.error(str(e))
378
- try:
379
- with ConfluenceAPI(properties) as api:
380
- Publisher(
381
- api,
382
- options,
383
- ).process(args.mdpath)
384
- except HTTPError as err:
385
- logging.error(err)
386
-
387
- # print details for a response with JSON body
388
- if err.response is not None:
389
- try:
390
- logging.error(err.response.json())
391
- except JSONDecodeError:
392
- pass
393
-
394
- sys.exit(1)
430
+ with ConfluenceAPI(properties) as api:
431
+ publisher = Publisher(api, options)
432
+ for item in args.mdpath:
433
+ publisher.process(item)
395
434
 
396
435
 
397
436
  if __name__ == "__main__":
md2conf/api.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
4
+ Copyright 2022-2026, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -25,8 +25,8 @@ import requests
25
25
  import truststore
26
26
  from requests.adapters import HTTPAdapter
27
27
 
28
- from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
29
- from .extra import override
28
+ from .compatibility import override
29
+ from .environment import ArgumentError, ConfluenceError, ConnectionProperties, PageError
30
30
  from .metadata import ConfluenceSiteMetadata
31
31
  from .serializer import JsonType, json_to_object, object_to_json_payload
32
32
 
@@ -35,6 +35,7 @@ T = TypeVar("T")
35
35
  # spellchecker: disable
36
36
  mimetypes.add_type("application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx", strict=True)
37
37
  mimetypes.add_type("text/vnd.mermaid", ".mmd", strict=True)
38
+ mimetypes.add_type("text/vnd.plantuml", ".puml", strict=True)
38
39
  mimetypes.add_type("application/vnd.oasis.opendocument.presentation", ".odp", strict=True)
39
40
  mimetypes.add_type("application/vnd.oasis.opendocument.spreadsheet", ".ods", strict=True)
40
41
  mimetypes.add_type("application/vnd.oasis.opendocument.text", ".odt", strict=True)
@@ -370,11 +371,11 @@ class ConfluenceAPI:
370
371
  Encapsulates operations that can be invoked via the [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/).
371
372
  """
372
373
 
373
- properties: ConfluenceConnectionProperties
374
+ properties: ConnectionProperties
374
375
  session: "ConfluenceSession | None" = None
375
376
 
376
- def __init__(self, properties: ConfluenceConnectionProperties | None = None) -> None:
377
- self.properties = properties or ConfluenceConnectionProperties()
377
+ def __init__(self, properties: ConnectionProperties | None = None) -> None:
378
+ self.properties = properties or ConnectionProperties()
378
379
 
379
380
  def __enter__(self) -> "ConfluenceSession":
380
381
  """
@@ -626,7 +627,16 @@ class ConfluenceSession:
626
627
 
627
628
  return id
628
629
 
630
+ @overload
631
+ def get_space_id(self, *, space_id: str | None = None) -> str | None: ...
632
+
633
+ @overload
634
+ def get_space_id(self, *, space_key: str | None = None) -> str | None: ...
635
+
629
636
  def get_space_id(self, *, space_id: str | None = None, space_key: str | None = None) -> str | None:
637
+ return self._get_space_id(space_id=space_id, space_key=space_key)
638
+
639
+ def _get_space_id(self, *, space_id: str | None = None, space_key: str | None = None) -> str | None:
630
640
  """
631
641
  Coalesces a space ID or space key into a space ID, accounting for site default.
632
642
 
@@ -647,6 +657,15 @@ class ConfluenceSession:
647
657
  # space ID and key are unset, and no default space is configured
648
658
  return None
649
659
 
660
+ def get_homepage_id(self, space_id: str) -> str:
661
+ """
662
+ Returns the page ID corresponding to the space home page.
663
+ """
664
+
665
+ path = f"/spaces/{space_id}"
666
+ data = self._get(ConfluenceVersion.VERSION_2, path, dict[str, JsonType])
667
+ return typing.cast(str, data["homepageId"])
668
+
650
669
  def get_attachment_by_name(self, page_id: str, filename: str) -> ConfluenceAttachment:
651
670
  """
652
671
  Retrieves a Confluence page attachment by an unprefixed file name.
@@ -831,7 +850,7 @@ class ConfluenceSession:
831
850
  query = {
832
851
  "title": title,
833
852
  }
834
- space_id = self.get_space_id(space_id=space_id, space_key=space_key)
853
+ space_id = self._get_space_id(space_id=space_id, space_key=space_key)
835
854
  if space_id is not None:
836
855
  query["space-id"] = space_id
837
856
 
@@ -871,10 +890,10 @@ class ConfluenceSession:
871
890
  else:
872
891
  raise
873
892
 
874
- # This should not be reached, but satisfies type checker
893
+ # this should not be reached, but satisfies type checker
875
894
  if last_error is not None:
876
895
  raise last_error
877
- raise ConfluenceError(f"Failed to get page {page_id}")
896
+ raise ConfluenceError(f"failed to get page: {page_id}")
878
897
 
879
898
  def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
880
899
  """
@@ -1006,7 +1025,7 @@ class ConfluenceSession:
1006
1025
  :returns: Confluence page ID of a matching page (if found), or `None`.
1007
1026
  """
1008
1027
 
1009
- space_id = self.get_space_id(space_id=space_id, space_key=space_key)
1028
+ space_id = self._get_space_id(space_id=space_id, space_key=space_key)
1010
1029
  path = "/pages"
1011
1030
  query = {"title": title}
1012
1031
  if space_id is not None:
@@ -1039,6 +1058,7 @@ class ConfluenceSession:
1039
1058
 
1040
1059
  :param title: Page title. Pages in the same Confluence space must have a unique title.
1041
1060
  :param parent_id: Identifies the parent page for a new child page.
1061
+ :returns: Confluence page info for the found or newly created page.
1042
1062
  """
1043
1063
 
1044
1064
  parent_page = self.get_page_properties(parent_id)
md2conf/attachment.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2026, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass
15
+ class ImageData:
16
+ path: Path
17
+ description: str | None = None
18
+
19
+
20
+ @dataclass
21
+ class EmbeddedFileData:
22
+ data: bytes
23
+ description: str | None = None
24
+
25
+
26
+ class AttachmentCatalog:
27
+ "Maintains a list of files and binary data to be uploaded to Confluence as attachments."
28
+
29
+ images: list[ImageData]
30
+ embedded_files: dict[str, EmbeddedFileData]
31
+
32
+ def __init__(self) -> None:
33
+ self.images = []
34
+ self.embedded_files = {}
35
+
36
+ def add_image(self, data: ImageData) -> None:
37
+ self.images.append(data)
38
+
39
+ def add_embed(self, filename: str, data: EmbeddedFileData) -> None:
40
+ self.embedded_files[filename] = data
41
+
42
+
43
+ def attachment_name(ref: Path | str) -> str:
44
+ """
45
+ Safe name for use with attachment uploads.
46
+
47
+ Mutates a relative path such that it meets Confluence's attachment naming requirements.
48
+
49
+ Allowed characters:
50
+
51
+ * Alphanumeric characters: 0-9, a-z, A-Z
52
+ * Special characters: hyphen (-), underscore (_), period (.)
53
+ """
54
+
55
+ if isinstance(ref, Path):
56
+ path = ref
57
+ else:
58
+ path = Path(ref)
59
+
60
+ if path.drive or path.root:
61
+ raise ValueError(f"required: relative path; got: {ref}")
62
+
63
+ regexp = re.compile(r"[^\-0-9A-Za-z_.]", re.UNICODE)
64
+
65
+ def replace_part(part: str) -> str:
66
+ if part == "..":
67
+ return "PAR"
68
+ else:
69
+ return regexp.sub("_", part)
70
+
71
+ parts = [replace_part(p) for p in path.parts]
72
+ return Path(*parts).as_posix().replace("/", "_")
md2conf/coalesce.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2026, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ import copy
10
+ import dataclasses
11
+ from typing import Any, ClassVar, Protocol, TypeVar
12
+
13
+
14
+ class DataclassInstance(Protocol):
15
+ __dataclass_fields__: ClassVar[dict[str, dataclasses.Field[Any]]]
16
+
17
+
18
+ D = TypeVar("D", bound=DataclassInstance)
19
+
20
+
21
+ def coalesce(target: D, source: D) -> D:
22
+ """
23
+ Implements nullish coalescing assignment on each field of a data-class.
24
+
25
+ Iterates over each field of the data-class, and evaluates the right operand and assigns it to the left only if
26
+ the left operand is `None`. Applies recursively when the field is a data-class.
27
+
28
+ :returns: A newly created data-class instance.
29
+ """
30
+
31
+ updates: dict[str, Any] = {}
32
+ for field in dataclasses.fields(target):
33
+ target_field = getattr(target, field.name, None)
34
+ source_field = getattr(source, field.name, None)
35
+
36
+ if target_field is None:
37
+ if source_field is not None:
38
+ updates[field.name] = copy.deepcopy(source_field)
39
+ elif dataclasses.is_dataclass(field.type):
40
+ if source_field is not None:
41
+ updates[field.name] = coalesce(target_field, source_field)
42
+
43
+ return dataclasses.replace(target, **updates)
md2conf/collection.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
4
+ Copyright 2022-2026, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
4
+ Copyright 2022-2026, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """