markdown-to-confluence 0.3.0__tar.gz → 0.3.2__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 (31) hide show
  1. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/PKG-INFO +3 -2
  2. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/PKG-INFO +3 -2
  3. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/SOURCES.txt +0 -1
  4. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/__init__.py +1 -1
  5. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/__main__.py +8 -0
  6. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/api.py +48 -28
  7. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/application.py +9 -0
  8. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/converter.py +10 -17
  9. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/mermaid.py +5 -1
  10. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/properties.py +1 -3
  11. markdown_to_confluence-0.3.0/md2conf/util.py +0 -27
  12. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/LICENSE +0 -0
  13. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/README.md +0 -0
  14. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
  15. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/entry_points.txt +0 -0
  16. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/requires.txt +0 -0
  17. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/top_level.txt +0 -0
  18. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/markdown_to_confluence.egg-info/zip-safe +0 -0
  19. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/emoji.py +0 -0
  20. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/entities.dtd +0 -0
  21. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/matcher.py +0 -0
  22. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/processor.py +0 -0
  23. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/puppeteer-config.json +0 -0
  24. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/md2conf/py.typed +0 -0
  25. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/pyproject.toml +0 -0
  26. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/setup.cfg +0 -0
  27. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/setup.py +0 -0
  28. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/tests/test_conversion.py +0 -0
  29. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/tests/test_matcher.py +0 -0
  30. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/tests/test_mermaid.py +0 -0
  31. {markdown_to_confluence-0.3.0 → markdown_to_confluence-0.3.2}/tests/test_processor.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -30,6 +30,7 @@ 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
33
+ Dynamic: license-file
33
34
 
34
35
  # Publish Markdown files to Confluence wiki
35
36
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -30,6 +30,7 @@ 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
33
+ Dynamic: license-file
33
34
 
34
35
  # Publish Markdown files to Confluence wiki
35
36
 
@@ -23,7 +23,6 @@ md2conf/processor.py
23
23
  md2conf/properties.py
24
24
  md2conf/puppeteer-config.json
25
25
  md2conf/py.typed
26
- md2conf/util.py
27
26
  tests/test_conversion.py
28
27
  tests/test_matcher.py
29
28
  tests/test_mermaid.py
@@ -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.0"
8
+ __version__ = "0.3.2"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
@@ -38,6 +38,7 @@ class Arguments(argparse.Namespace):
38
38
  ignore_invalid_url: bool
39
39
  heading_anchors: bool
40
40
  root_page: Optional[str]
41
+ keep_hierarchy: bool
41
42
  generated_by: Optional[str]
42
43
  render_mermaid: bool
43
44
  diagram_output_format: Literal["png", "svg"]
@@ -109,6 +110,12 @@ def main() -> None:
109
110
  dest="root_page",
110
111
  help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
111
112
  )
113
+ parser.add_argument(
114
+ "--keep-hierarchy",
115
+ action="store_true",
116
+ default=False,
117
+ help="Maintain source directory structure when exporting to Confluence.",
118
+ )
112
119
  parser.add_argument(
113
120
  "--generated-by",
114
121
  default="This page has been generated with a tool.",
@@ -189,6 +196,7 @@ def main() -> None:
189
196
  ignore_invalid_url=args.ignore_invalid_url,
190
197
  generated_by=args.generated_by,
191
198
  root_page_id=args.root_page,
199
+ keep_hierarchy=args.keep_hierarchy,
192
200
  render_mermaid=args.render_mermaid,
193
201
  diagram_output_format=args.diagram_output_format,
194
202
  webui_links=args.webui_links,
@@ -22,7 +22,6 @@ import requests
22
22
 
23
23
  from .converter import ParseError, sanitize_confluence
24
24
  from .properties import ConfluenceError, ConfluenceProperties
25
- from .util import removeprefix
26
25
 
27
26
  # a JSON type with possible `null` values
28
27
  JsonType = Union[
@@ -41,6 +40,17 @@ class ConfluenceVersion(enum.Enum):
41
40
  VERSION_2 = "api/v2"
42
41
 
43
42
 
43
+ class ConfluencePageParentContentType(enum.Enum):
44
+ """
45
+ Content types that can be a parent to a Confluence page
46
+ """
47
+ PAGE = "page"
48
+ WHITEBOARD = "whiteboard"
49
+ DATABASE = "database"
50
+ EMBED = "embed"
51
+ FOLDER = "folder"
52
+
53
+
44
54
  def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
45
55
  "Builds a URL with scheme, host, port, path and query string parameters."
46
56
 
@@ -72,6 +82,8 @@ class ConfluenceAttachment:
72
82
  class ConfluencePage:
73
83
  id: str
74
84
  space_id: str
85
+ parent_id: str
86
+ parent_type: ConfluencePageParentContentType
75
87
  title: str
76
88
  version: int
77
89
  content: str
@@ -119,13 +131,17 @@ class ConfluenceSession:
119
131
  session: requests.Session
120
132
  domain: str
121
133
  base_path: str
122
- space_key: str
134
+ space_key: Optional[str]
123
135
 
124
136
  _space_id_to_key: dict[str, str]
125
137
  _space_key_to_id: dict[str, str]
126
138
 
127
139
  def __init__(
128
- self, session: requests.Session, domain: str, base_path: str, space_key: str
140
+ self,
141
+ session: requests.Session,
142
+ domain: str,
143
+ base_path: str,
144
+ space_key: Optional[str],
129
145
  ) -> None:
130
146
  self.session = session
131
147
  self.domain = domain
@@ -168,6 +184,10 @@ class ConfluenceSession:
168
184
  url = self._build_url(version, path, query)
169
185
  response = self.session.get(url)
170
186
  response.raise_for_status()
187
+ if len(response.text) > 240:
188
+ LOGGER.debug("Received HTTP payload (truncated):\n%.240s...", response.text)
189
+ else:
190
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
171
191
  return response.json()
172
192
 
173
193
  def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
@@ -187,7 +207,7 @@ class ConfluenceSession:
187
207
  payload = self._invoke(
188
208
  ConfluenceVersion.VERSION_2,
189
209
  "/spaces",
190
- {"ids": id, "type": "global", "status": "current"},
210
+ {"ids": id, "status": "current"},
191
211
  )
192
212
  payload = typing.cast(dict[str, JsonType], payload)
193
213
  results = typing.cast(list[JsonType], payload["results"])
@@ -209,7 +229,7 @@ class ConfluenceSession:
209
229
  payload = self._invoke(
210
230
  ConfluenceVersion.VERSION_2,
211
231
  "/spaces",
212
- {"keys": key, "type": "global", "status": "current"},
232
+ {"keys": key, "status": "current"},
213
233
  )
214
234
  payload = typing.cast(dict[str, JsonType], payload)
215
235
  results = typing.cast(list[JsonType], payload["results"])
@@ -252,10 +272,8 @@ class ConfluenceSession:
252
272
  raw_data: Optional[bytes] = None,
253
273
  content_type: Optional[str] = None,
254
274
  comment: Optional[str] = None,
255
- space_key: Optional[str] = None,
256
275
  force: bool = False,
257
276
  ) -> None:
258
-
259
277
  if attachment_path is None and raw_data is None:
260
278
  raise ConfluenceError("required: `attachment_path` or `raw_data`")
261
279
 
@@ -286,7 +304,7 @@ class ConfluenceSession:
286
304
  else:
287
305
  raise NotImplementedError("never occurs")
288
306
 
289
- id = removeprefix(attachment.id, "att")
307
+ id = attachment.id.removeprefix("att")
290
308
  path = f"/content/{page_id}/child/attachment/{id}/data"
291
309
 
292
310
  except ConfluenceError:
@@ -345,27 +363,18 @@ class ConfluenceSession:
345
363
  version = result["version"]["number"] + 1
346
364
 
347
365
  # ensure path component is retained in attachment name
348
- self._update_attachment(
349
- page_id, attachment_id, version, attachment_name, space_key=space_key
350
- )
366
+ self._update_attachment(page_id, attachment_id, version, attachment_name)
351
367
 
352
368
  def _update_attachment(
353
- self,
354
- page_id: str,
355
- attachment_id: str,
356
- version: int,
357
- attachment_title: str,
358
- *,
359
- space_key: Optional[str] = None,
369
+ self, page_id: str, attachment_id: str, version: int, attachment_title: str
360
370
  ) -> None:
361
- id = removeprefix(attachment_id, "att")
371
+ id = attachment_id.removeprefix("att")
362
372
  path = f"/content/{page_id}/child/attachment/{id}"
363
373
  data = {
364
374
  "id": attachment_id,
365
375
  "type": "attachment",
366
376
  "status": "current",
367
377
  "title": attachment_title,
368
- "space": {"key": space_key or self.space_key},
369
378
  "version": {"minorEdit": True, "number": version},
370
379
  }
371
380
 
@@ -389,9 +398,12 @@ class ConfluenceSession:
389
398
  LOGGER.info("Looking up page with title: %s", title)
390
399
  path = "/pages"
391
400
  query = {
392
- "space-id": self.space_key_to_id(space_key or self.space_key),
393
401
  "title": title,
394
402
  }
403
+ coalesced_space_key = space_key or self.space_key
404
+ if coalesced_space_key is not None:
405
+ query["space-id"] = self.space_key_to_id(coalesced_space_key)
406
+
395
407
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
396
408
  payload = typing.cast(dict[str, JsonType], payload)
397
409
 
@@ -422,6 +434,8 @@ class ConfluenceSession:
422
434
  return ConfluencePage(
423
435
  id=page_id,
424
436
  space_id=typing.cast(str, data["spaceId"]),
437
+ parent_id=typing.cast(str, data["parentId"]),
438
+ parent_type=ConfluencePageParentContentType(typing.cast(str, data["parentType"])),
425
439
  title=typing.cast(str, data["title"]),
426
440
  version=typing.cast(int, version["number"]),
427
441
  content=typing.cast(str, storage["value"]),
@@ -432,7 +446,6 @@ class ConfluenceSession:
432
446
  Retrieve a Confluence wiki page version.
433
447
 
434
448
  :param page_id: The Confluence page ID.
435
- :param space_key: The Confluence space key (unless the default space is to be used).
436
449
  :returns: Confluence page version.
437
450
  """
438
451
 
@@ -454,7 +467,6 @@ class ConfluenceSession:
454
467
 
455
468
  :param page_id: The Confluence page ID.
456
469
  :param new_content: Confluence Storage Format XHTML.
457
- :param space_key: The Confluence space key (unless the default space is to be used).
458
470
  :param title: New title to assign to the page. Needs to be unique within a space.
459
471
  """
460
472
 
@@ -493,9 +505,15 @@ class ConfluenceSession:
493
505
  Create a new page via Confluence API.
494
506
  """
495
507
 
508
+ coalesced_space_key = space_key or self.space_key
509
+ if coalesced_space_key is None:
510
+ raise ConfluenceError(
511
+ "Confluence space key required for creating a new page"
512
+ )
513
+
496
514
  path = "/pages/"
497
515
  query = {
498
- "spaceId": self.space_key_to_id(space_key or self.space_key),
516
+ "spaceId": self.space_key_to_id(coalesced_space_key),
499
517
  "status": "current",
500
518
  "title": title,
501
519
  "parentId": parent_page_id,
@@ -520,6 +538,8 @@ class ConfluenceSession:
520
538
  return ConfluencePage(
521
539
  id=typing.cast(str, data["id"]),
522
540
  space_id=typing.cast(str, data["spaceId"]),
541
+ parent_id=typing.cast(str, data["parentId"]),
542
+ parent_type=ConfluencePageParentContentType(typing.cast(str, data["parentType"])),
523
543
  title=typing.cast(str, data["title"]),
524
544
  version=typing.cast(int, version["number"]),
525
545
  content=typing.cast(str, storage["value"]),
@@ -553,10 +573,10 @@ class ConfluenceSession:
553
573
  self, title: str, *, space_key: Optional[str] = None
554
574
  ) -> Optional[str]:
555
575
  path = "/pages"
556
- query = {
557
- "title": title,
558
- "space-id": self.space_key_to_id(space_key or self.space_key),
559
- }
576
+ coalesced_space_key = space_key or self.space_key
577
+ query = {"title": title}
578
+ if coalesced_space_key is not None:
579
+ query["space-id"] = self.space_key_to_id(coalesced_space_key)
560
580
 
561
581
  LOGGER.info("Checking if page exists with title: %s", title)
562
582
 
@@ -131,6 +131,15 @@ class Application:
131
131
  parent_doc = Path(local_dir) / "index.md"
132
132
  elif (Path(local_dir) / "README.md") in files:
133
133
  parent_doc = Path(local_dir) / "README.md"
134
+ elif (Path(local_dir) / f"{local_dir.name}.md") in files:
135
+ parent_doc = Path(local_dir) / f"{local_dir.name}.md"
136
+
137
+ if parent_doc is None and self.options.keep_hierarchy:
138
+ parent_doc = Path(local_dir) / "index.md"
139
+
140
+ # create a blank page in Confluence for the directory entry
141
+ with open(parent_doc, "w"):
142
+ pass
134
143
 
135
144
  if parent_doc is not None:
136
145
  files.remove(parent_doc)
@@ -13,7 +13,6 @@ import importlib.resources as resources
13
13
  import logging
14
14
  import os.path
15
15
  import re
16
- import sys
17
16
  import uuid
18
17
  import xml.etree.ElementTree
19
18
  from dataclasses import dataclass
@@ -129,7 +128,7 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
129
128
 
130
129
  data = [
131
130
  '<?xml version="1.0"?>',
132
- f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path}">'
131
+ f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}">'
133
132
  f"<root{ns_attr_list}>",
134
133
  ]
135
134
  data.extend(items)
@@ -144,13 +143,9 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
144
143
  def elements_from_strings(items: list[str]) -> ET._Element:
145
144
  "Creates a fragment of several XML nodes from their string representation wrapped in a root element."
146
145
 
147
- if sys.version_info >= (3, 9):
148
- resource_path = resources.files(__package__).joinpath("entities.dtd")
149
- with resources.as_file(resource_path) as dtd_path:
150
- return _elements_from_strings(dtd_path, items)
151
- else:
152
- with resources.path(__package__, "entities.dtd") as dtd_path:
153
- return _elements_from_strings(dtd_path, items)
146
+ resource_path = resources.files(__package__).joinpath("entities.dtd")
147
+ with resources.as_file(resource_path) as dtd_path:
148
+ return _elements_from_strings(dtd_path, items)
154
149
 
155
150
 
156
151
  def elements_from_string(content: str) -> ET._Element:
@@ -244,7 +239,7 @@ class ConfluencePageMetadata:
244
239
  domain: str
245
240
  base_path: str
246
241
  page_id: str
247
- space_key: str
242
+ space_key: Optional[str]
248
243
  title: str
249
244
 
250
245
 
@@ -976,6 +971,7 @@ class ConfluenceDocumentOptions:
976
971
  conversion rules for the identifier.
977
972
  :param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
978
973
  :param root_page_id: Confluence page to assume root page role for publishing a directory of Markdown files.
974
+ :param keep_hierarchy: Whether to maintain source directory structure when exporting to Confluence.
979
975
  :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
980
976
  :param diagram_output_format: Target image format for diagrams.
981
977
  :param webui_links: When true, convert relative URLs to Confluence Web UI links.
@@ -985,6 +981,7 @@ class ConfluenceDocumentOptions:
985
981
  heading_anchors: bool = False
986
982
  generated_by: Optional[str] = "This page has been generated with a tool."
987
983
  root_page_id: Optional[str] = None
984
+ keep_hierarchy: bool = False
988
985
  render_mermaid: bool = False
989
986
  diagram_output_format: Literal["png", "svg"] = "png"
990
987
  webui_links: bool = False
@@ -1132,10 +1129,6 @@ def _content_to_string(dtd_path: Path, content: str) -> str:
1132
1129
  def content_to_string(content: str) -> str:
1133
1130
  "Converts a Confluence Storage Format document returned by the API into a readable XML document."
1134
1131
 
1135
- if sys.version_info >= (3, 9):
1136
- resource_path = resources.files(__package__).joinpath("entities.dtd")
1137
- with resources.as_file(resource_path) as dtd_path:
1138
- return _content_to_string(dtd_path, content)
1139
- else:
1140
- with resources.path(__package__, "entities.dtd") as dtd_path:
1141
- return _content_to_string(dtd_path, content)
1132
+ resource_path = resources.files(__package__).joinpath("entities.dtd")
1133
+ with resources.as_file(resource_path) as dtd_path:
1134
+ return _content_to_string(dtd_path, content)
@@ -29,7 +29,11 @@ def get_mmdc() -> str:
29
29
  "Path to the Mermaid diagram converter."
30
30
 
31
31
  if is_docker():
32
- return "/home/md2conf/node_modules/.bin/mmdc"
32
+ full_path = "/home/md2conf/node_modules/.bin/mmdc"
33
+ if os.path.exists(full_path):
34
+ return full_path
35
+ else:
36
+ return "mmdc"
33
37
  elif os.name == "nt":
34
38
  return "mmdc.cmd"
35
39
  else:
@@ -17,7 +17,7 @@ class ConfluenceError(RuntimeError):
17
17
  class ConfluenceProperties:
18
18
  domain: str
19
19
  base_path: str
20
- space_key: str
20
+ space_key: Optional[str]
21
21
  user_name: Optional[str]
22
22
  api_key: str
23
23
  headers: Optional[dict[str, str]]
@@ -43,8 +43,6 @@ class ConfluenceProperties:
43
43
  opt_base_path = "/wiki/"
44
44
  if not opt_api_key:
45
45
  raise ConfluenceError("Confluence API key not specified")
46
- if not opt_space_key:
47
- raise ConfluenceError("Confluence space key not specified")
48
46
 
49
47
  if opt_domain.startswith(("http://", "https://")) or opt_domain.endswith("/"):
50
48
  raise ConfluenceError(
@@ -1,27 +0,0 @@
1
- """
2
- Publish Markdown files to Confluence wiki.
3
-
4
- Copyright 2022-2025, Levente Hunyadi
5
-
6
- :see: https://github.com/hunyadi/md2conf
7
- """
8
-
9
- import sys
10
-
11
- if sys.version_info >= (3, 9):
12
-
13
- def removeprefix(string: str, prefix: str) -> str:
14
- "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
15
-
16
- return string.removeprefix(prefix)
17
-
18
- else:
19
-
20
- def removeprefix(string: str, prefix: str) -> str:
21
- "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
22
-
23
- if string.startswith(prefix):
24
- prefix_len = len(prefix)
25
- return string[prefix_len:]
26
- else:
27
- return string