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.
md2conf/application.py CHANGED
@@ -6,22 +6,21 @@ Copyright 2022-2025, Levente Hunyadi
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
- import hashlib
10
9
  import logging
11
10
  from pathlib import Path
12
11
  from typing import Optional
13
12
 
14
- from .api import ConfluencePage, ConfluenceSession
13
+ from .api import ConfluenceLabel, ConfluenceSession
15
14
  from .converter import (
16
15
  ConfluenceDocument,
17
16
  ConfluenceDocumentOptions,
18
17
  ConfluencePageID,
19
18
  attachment_name,
20
19
  )
20
+ from .extra import override
21
21
  from .metadata import ConfluencePageMetadata
22
- from .processor import Converter, Processor, ProcessorFactory
22
+ from .processor import Converter, DocumentNode, Processor, ProcessorFactory
23
23
  from .properties import PageError
24
- from .scanner import Scanner
25
24
 
26
25
  LOGGER = logging.getLogger(__name__)
27
26
 
@@ -47,69 +46,76 @@ class SynchronizingProcessor(Processor):
47
46
  super().__init__(options, api.site, root_dir)
48
47
  self.api = api
49
48
 
50
- def _get_or_create_page(
51
- self, absolute_path: Path, parent_id: Optional[ConfluencePageID]
52
- ) -> ConfluencePageMetadata:
53
- """
54
- Creates a new Confluence page if no page is linked in the Markdown document.
49
+ @override
50
+ def _synchronize_tree(
51
+ self, root: DocumentNode, root_id: Optional[ConfluencePageID]
52
+ ) -> None:
55
53
  """
54
+ Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
55
+
56
+ Creates new Confluence pages as necessary, e.g. if no page is linked in the Markdown document, or no page is found with lookup by page title.
56
57
 
57
- # parse file
58
- document = Scanner().read(absolute_path)
58
+ Updates the original Markdown document to add tags to associate the document with its corresponding Confluence page.
59
+ """
59
60
 
60
- overwrite = False
61
- if document.page_id is None:
62
- # create new Confluence page
63
- if parent_id is None:
61
+ if root.page_id is None and root_id is None:
62
+ raise PageError(
63
+ f"expected: root page ID in options, or explicit page ID in {root.absolute_path}"
64
+ )
65
+ elif root.page_id is not None and root_id is not None:
66
+ if root.page_id != root_id.page_id:
64
67
  raise PageError(
65
- f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
68
+ f"mismatched inferred page ID of {root_id.page_id} and explicit page ID in {root.absolute_path}"
66
69
  )
67
70
 
68
- # use file name (without extension) and path hash if no title is supplied
69
- if document.title is not None:
70
- title = document.title
71
- else:
72
- overwrite = True
73
- relative_path = absolute_path.relative_to(self.root_dir)
74
- hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
75
- digest = "".join(f"{c:x}" for c in hash.digest())
76
- title = f"{absolute_path.stem} [{digest}]"
77
-
78
- confluence_page = self._create_page(
79
- absolute_path, document.text, title, parent_id
80
- )
71
+ real_id = root_id
72
+ elif root_id is not None:
73
+ real_id = root_id
74
+ elif root.page_id is not None:
75
+ real_id = ConfluencePageID(root.page_id)
81
76
  else:
82
- # look up existing Confluence page
83
- confluence_page = self.api.get_page(document.page_id)
84
-
85
- return ConfluencePageMetadata(
86
- page_id=confluence_page.id,
87
- space_key=self.api.space_id_to_key(confluence_page.space_id),
88
- title=confluence_page.title,
89
- overwrite=overwrite,
90
- )
77
+ raise NotImplementedError("condition not exhaustive")
91
78
 
92
- def _create_page(
93
- self,
94
- absolute_path: Path,
95
- document: str,
96
- title: str,
97
- parent_id: ConfluencePageID,
98
- ) -> ConfluencePage:
99
- """
100
- Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
101
- """
79
+ self._synchronize_subtree(root, real_id)
102
80
 
103
- confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
104
- self._update_markdown(
105
- absolute_path,
106
- document,
107
- confluence_page.id,
108
- self.api.space_id_to_key(confluence_page.space_id),
81
+ def _synchronize_subtree(
82
+ self, node: DocumentNode, parent_id: ConfluencePageID
83
+ ) -> None:
84
+ if node.page_id is not None:
85
+ # verify if page exists
86
+ page = self.api.get_page_properties(node.page_id)
87
+ update = False
88
+ elif node.title is not None:
89
+ # look up page by title
90
+ page = self.api.get_or_create_page(node.title, parent_id.page_id)
91
+ update = True
92
+ else:
93
+ # always create a new page
94
+ digest = self._generate_hash(node.absolute_path)
95
+ title = f"{node.absolute_path.stem} [{digest}]"
96
+ page = self.api.create_page(parent_id.page_id, title, "")
97
+ update = True
98
+
99
+ space_key = self.api.space_id_to_key(page.spaceId)
100
+ if update:
101
+ self._update_markdown(
102
+ node.absolute_path,
103
+ page_id=page.id,
104
+ space_key=space_key,
105
+ )
106
+
107
+ data = ConfluencePageMetadata(
108
+ page_id=page.id,
109
+ space_key=space_key,
110
+ title=page.title,
109
111
  )
110
- return confluence_page
112
+ self.page_metadata.add(node.absolute_path, data)
113
+
114
+ for child_node in node.children():
115
+ self._synchronize_subtree(child_node, ConfluencePageID(page.id))
111
116
 
112
- def _save_document(
117
+ @override
118
+ def _update_page(
113
119
  self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
114
120
  ) -> None:
115
121
  """
@@ -138,10 +144,12 @@ class SynchronizingProcessor(Processor):
138
144
 
139
145
  title = None
140
146
  if document.title is not None:
141
- meta = self.page_metadata[path]
142
-
143
- # update title only for pages with randomly assigned title
144
- if meta.overwrite:
147
+ meta = self.page_metadata.get(path)
148
+ if (
149
+ meta is not None
150
+ and meta.space_key is not None
151
+ and meta.title != document.title
152
+ ):
145
153
  conflicting_page_id = self.api.page_exists(
146
154
  document.title, space_id=self.api.space_key_to_id(meta.space_key)
147
155
  )
@@ -156,17 +164,23 @@ class SynchronizingProcessor(Processor):
156
164
 
157
165
  self.api.update_page(page_id.page_id, content, title=title)
158
166
 
159
- def _update_markdown(
160
- self,
161
- path: Path,
162
- document: str,
163
- page_id: str,
164
- space_key: Optional[str],
165
- ) -> None:
167
+ if document.labels is not None:
168
+ self.api.update_labels(
169
+ page_id.page_id,
170
+ [
171
+ ConfluenceLabel(name=label, prefix="global")
172
+ for label in document.labels
173
+ ],
174
+ )
175
+
176
+ def _update_markdown(self, path: Path, *, page_id: str, space_key: str) -> None:
166
177
  """
167
178
  Writes the Confluence page ID and space key at the beginning of the Markdown file.
168
179
  """
169
180
 
181
+ with open(path, "r", encoding="utf-8") as file:
182
+ document = file.read()
183
+
170
184
  content: list[str] = []
171
185
 
172
186
  # check if the file has frontmatter
@@ -178,9 +192,7 @@ class SynchronizingProcessor(Processor):
178
192
  content.append(document[:index])
179
193
 
180
194
  content.append(f"<!-- confluence-page-id: {page_id} -->")
181
- if space_key:
182
- content.append(f"<!-- confluence-space-key: {space_key} -->")
183
-
195
+ content.append(f"<!-- confluence-space-key: {space_key} -->")
184
196
  content.append(document[index:])
185
197
 
186
198
  with open(path, "w", encoding="utf-8") as file:
md2conf/collection.py ADDED
@@ -0,0 +1,31 @@
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
+ from pathlib import Path
10
+ from typing import Iterable, Optional
11
+
12
+ from .metadata import ConfluencePageMetadata
13
+
14
+
15
+ class ConfluencePageCollection:
16
+ _metadata: dict[Path, ConfluencePageMetadata]
17
+
18
+ def __init__(self) -> None:
19
+ self._metadata = {}
20
+
21
+ def __len__(self) -> int:
22
+ return len(self._metadata)
23
+
24
+ def add(self, path: Path, data: ConfluencePageMetadata) -> None:
25
+ self._metadata[path] = data
26
+
27
+ def get(self, path: Path) -> Optional[ConfluencePageMetadata]:
28
+ return self._metadata.get(path)
29
+
30
+ def items(self) -> Iterable[tuple[Path, ConfluencePageMetadata]]:
31
+ return self._metadata.items()
md2conf/converter.py CHANGED
@@ -24,8 +24,9 @@ import lxml.etree as ET
24
24
  import markdown
25
25
  from lxml.builder import ElementMaker
26
26
 
27
+ from .collection import ConfluencePageCollection
27
28
  from .mermaid import render_diagram
28
- from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
29
+ from .metadata import ConfluenceSiteMetadata
29
30
  from .properties import PageError
30
31
  from .scanner import ScannedDocument, Scanner
31
32
 
@@ -91,8 +92,10 @@ def emoji_generator(
91
92
  md: markdown.Markdown,
92
93
  ) -> xml.etree.ElementTree.Element:
93
94
  name = (alias or shortname).strip(":")
94
- span = xml.etree.ElementTree.Element("span", {"data-emoji": name})
95
+ span = xml.etree.ElementTree.Element("span", {"data-emoji-shortname": name})
95
96
  if uc is not None:
97
+ span.attrib["data-emoji-unicode"] = uc
98
+
96
99
  # convert series of Unicode code point hexadecimal values into characters
97
100
  span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
98
101
  else:
@@ -362,7 +365,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
362
365
  images: list[Path]
363
366
  embedded_images: dict[str, bytes]
364
367
  site_metadata: ConfluenceSiteMetadata
365
- page_metadata: dict[Path, ConfluencePageMetadata]
368
+ page_metadata: ConfluencePageCollection
366
369
 
367
370
  def __init__(
368
371
  self,
@@ -370,7 +373,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
370
373
  path: Path,
371
374
  root_dir: Path,
372
375
  site_metadata: ConfluenceSiteMetadata,
373
- page_metadata: dict[Path, ConfluencePageMetadata],
376
+ page_metadata: ConfluencePageCollection,
374
377
  ) -> None:
375
378
  super().__init__()
376
379
  self.options = options
@@ -834,7 +837,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
834
837
  )
835
838
 
836
839
  def _transform_emoji(self, elem: ET._Element) -> ET._Element:
837
- shortname = elem.attrib.get("data-emoji", "")
840
+ shortname = elem.attrib.get("data-emoji-shortname", "")
841
+ unicode = elem.attrib.get("data-emoji-unicode", None)
838
842
  alt = elem.text or ""
839
843
 
840
844
  # <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="&#128521;"/>
@@ -844,8 +848,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
844
848
  "emoticon",
845
849
  {
846
850
  # use "blue-star" as a placeholder name to ensure wiki page loads in timely manner
847
- ET.QName(namespaces["ac"], "name"): "blue-star",
851
+ ET.QName(namespaces["ac"], "name"): shortname,
848
852
  ET.QName(namespaces["ac"], "emoji-shortname"): f":{shortname}:",
853
+ ET.QName(namespaces["ac"], "emoji-id"): unicode,
849
854
  ET.QName(namespaces["ac"], "emoji-fallback"): alt,
850
855
  },
851
856
  )
@@ -943,7 +948,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
943
948
  elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
944
949
  return self._transform_block(child[0])
945
950
 
946
- elif child.tag == "span" and child.attrib.has_key("data-emoji"):
951
+ elif child.tag == "span" and child.attrib.has_key("data-emoji-shortname"):
947
952
  return self._transform_emoji(child)
948
953
 
949
954
  return None
@@ -1006,6 +1011,7 @@ class ConversionError(RuntimeError):
1006
1011
 
1007
1012
  class ConfluenceDocument:
1008
1013
  title: Optional[str]
1014
+ labels: Optional[list[str]]
1009
1015
  links: list[str]
1010
1016
  images: list[Path]
1011
1017
 
@@ -1019,7 +1025,7 @@ class ConfluenceDocument:
1019
1025
  options: ConfluenceDocumentOptions,
1020
1026
  root_dir: Path,
1021
1027
  site_metadata: ConfluenceSiteMetadata,
1022
- page_metadata: dict[Path, ConfluencePageMetadata],
1028
+ page_metadata: ConfluencePageCollection,
1023
1029
  ) -> tuple[ConfluencePageID, "ConfluenceDocument"]:
1024
1030
  path = path.resolve(True)
1025
1031
 
@@ -1046,7 +1052,7 @@ class ConfluenceDocument:
1046
1052
  options: ConfluenceDocumentOptions,
1047
1053
  root_dir: Path,
1048
1054
  site_metadata: ConfluenceSiteMetadata,
1049
- page_metadata: dict[Path, ConfluencePageMetadata],
1055
+ page_metadata: ConfluencePageCollection,
1050
1056
  ) -> None:
1051
1057
  self.options = options
1052
1058
 
@@ -1095,6 +1101,7 @@ class ConfluenceDocument:
1095
1101
  self.embedded_images = converter.embedded_images
1096
1102
 
1097
1103
  self.title = document.title or converter.toc.get_title()
1104
+ self.labels = document.tags
1098
1105
 
1099
1106
  def xhtml(self) -> str:
1100
1107
  return elements_to_string(self.root)
@@ -1146,7 +1153,7 @@ def _content_to_string(dtd_path: Path, content: str) -> str:
1146
1153
 
1147
1154
  data = [
1148
1155
  '<?xml version="1.0"?>',
1149
- f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path}">'
1156
+ f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path.as_posix()}">'
1150
1157
  f"<root{ns_attr_list}>",
1151
1158
  ]
1152
1159
  data.append(content)
md2conf/emoji.py CHANGED
@@ -10,7 +10,24 @@ import pathlib
10
10
 
11
11
  import pymdownx.emoji1_db as emoji_db
12
12
 
13
- EMOJI_PAGE_ID = "86918529216"
13
+ EMOJI_PAGE_ID = "13500452"
14
+
15
+
16
+ def to_html(cp: int) -> str:
17
+ """
18
+ Returns the safe HTML representation for a Unicode code point.
19
+
20
+ Converts non-ASCII and non-printable characters into HTML entities with decimal notation.
21
+
22
+ :param cp: Unicode code point.
23
+ :returns: An HTML representation of the Unicode character.
24
+ """
25
+
26
+ ch = chr(cp)
27
+ if ch.isascii() and ch.isalnum():
28
+ return ch
29
+ else:
30
+ return f"&#{cp};"
14
31
 
15
32
 
16
33
  def generate_source(path: pathlib.Path) -> None:
@@ -47,11 +64,19 @@ def generate_target(path: pathlib.Path) -> None:
47
64
  print("<thead><tr><th>Icon</th><th>Emoji code</th></tr></thead>", file=f)
48
65
  print("<tbody>", file=f)
49
66
  for key, data in emojis.items():
67
+ unicode = data["unicode"]
50
68
  key = key.strip(":")
51
- unicode = "".join(f"&#x{item};" for item in data["unicode"].split("-"))
69
+ html = "".join(to_html(int(item, base=16)) for item in unicode.split("-"))
52
70
 
53
71
  print(
54
- f'<tr><td><ac:emoticon ac:name="blue-star" ac:emoji-shortname=":{key}:" ac:emoji-fallback="{unicode}"/></td><td><code>:{key}:</code></td></tr>',
72
+ f"<tr>\n"
73
+ f" <td>\n"
74
+ f' <ac:emoticon ac:name="{key}" ac:emoji-shortname=":{key}:" ac:emoji-id="{unicode}" ac:emoji-fallback="{html}"/>\n'
75
+ f" </td>\n"
76
+ f" <td>\n"
77
+ f" <code>:{key}:</code>\n"
78
+ f" </td>\n"
79
+ f"</tr>",
55
80
  file=f,
56
81
  )
57
82
  print("</tbody>", file=f)
md2conf/extra.py ADDED
@@ -0,0 +1,14 @@
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, 12):
12
+ from typing import override as override # noqa: F401
13
+ else:
14
+ from typing_extensions import override as override # noqa: F401
md2conf/local.py CHANGED
@@ -6,17 +6,15 @@ Copyright 2022-2025, Levente Hunyadi
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
- import hashlib
10
9
  import logging
11
10
  import os
12
11
  from pathlib import Path
13
12
  from typing import Optional
14
13
 
15
14
  from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
15
+ from .extra import override
16
16
  from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
17
- from .processor import Converter, Processor, ProcessorFactory
18
- from .properties import PageError
19
- from .scanner import Scanner
17
+ from .processor import Converter, DocumentNode, Processor, ProcessorFactory
20
18
 
21
19
  LOGGER = logging.getLogger(__name__)
22
20
 
@@ -46,44 +44,41 @@ class LocalProcessor(Processor):
46
44
  super().__init__(options, site, root_dir)
47
45
  self.out_dir = out_dir or root_dir
48
46
 
49
- def _get_or_create_page(
50
- self, absolute_path: Path, parent_id: Optional[ConfluencePageID]
51
- ) -> ConfluencePageMetadata:
47
+ @override
48
+ def _synchronize_tree(
49
+ self, root: DocumentNode, root_id: Optional[ConfluencePageID]
50
+ ) -> None:
52
51
  """
53
- Extracts metadata from a Markdown file.
52
+ Creates the cross-reference index.
53
+
54
+ Does not change Markdown files.
54
55
  """
55
56
 
56
- # parse file
57
- document = Scanner().read(absolute_path)
58
- if document.page_id is not None:
59
- page_id = document.page_id
60
- space_key = document.space_key or self.site.space_key or "HOME"
61
- else:
62
- if parent_id is None:
63
- raise PageError(
64
- f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
57
+ for node in root.all():
58
+ if node.page_id is not None:
59
+ page_id = node.page_id
60
+ else:
61
+ digest = self._generate_hash(node.absolute_path)
62
+ LOGGER.info(
63
+ "Identifier %s assigned to page: %s", digest, node.absolute_path
65
64
  )
66
-
67
- hash = hashlib.md5(document.text.encode("utf-8"))
68
- digest = "".join(f"{c:x}" for c in hash.digest())
69
- LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
70
- page_id = digest
71
- space_key = self.site.space_key or "HOME"
72
-
73
- return ConfluencePageMetadata(
74
- page_id=page_id,
75
- space_key=space_key,
76
- title="",
77
- overwrite=True,
78
- )
79
-
80
- def _save_document(
65
+ page_id = digest
66
+
67
+ self.page_metadata.add(
68
+ node.absolute_path,
69
+ ConfluencePageMetadata(
70
+ page_id=page_id,
71
+ space_key=node.space_key or self.site.space_key or "HOME",
72
+ title=node.title or "",
73
+ ),
74
+ )
75
+
76
+ @override
77
+ def _update_page(
81
78
  self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
82
79
  ) -> None:
83
80
  """
84
- Saves a new version of a Confluence document.
85
-
86
- A derived class may invoke Confluence REST API to persist the new version.
81
+ Saves the document as Confluence Storage Format XHTML to the local disk.
87
82
  """
88
83
 
89
84
  content = document.xhtml()
md2conf/metadata.py CHANGED
@@ -33,10 +33,8 @@ class ConfluencePageMetadata:
33
33
  :param page_id: Confluence page ID.
34
34
  :param space_key: Confluence space key.
35
35
  :param title: Document title.
36
- :param overwrite: True if operations are allowed to update document properties (e.g. title).
37
36
  """
38
37
 
39
38
  page_id: str
40
39
  space_key: str
41
40
  title: str
42
- overwrite: bool