markdown-to-confluence 0.3.4__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,20 @@ 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
- extract_frontmatter_title,
21
- extract_qualified_id,
22
19
  )
20
+ from .extra import override
23
21
  from .metadata import ConfluencePageMetadata
24
- from .processor import Converter, Processor, ProcessorFactory
22
+ from .processor import Converter, DocumentNode, Processor, ProcessorFactory
25
23
  from .properties import PageError
26
24
 
27
25
  LOGGER = logging.getLogger(__name__)
@@ -48,82 +46,78 @@ class SynchronizingProcessor(Processor):
48
46
  super().__init__(options, api.site, root_dir)
49
47
  self.api = api
50
48
 
51
- def _get_or_create_page(
52
- self,
53
- absolute_path: Path,
54
- parent_id: Optional[ConfluencePageID],
55
- *,
56
- title: Optional[str] = None,
57
- ) -> ConfluencePageMetadata:
58
- """
59
- 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:
60
53
  """
54
+ Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
61
55
 
62
- # parse file
63
- with open(absolute_path, "r", encoding="utf-8") as f:
64
- text = f.read()
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.
65
57
 
66
- qualified_id, text = extract_qualified_id(text)
58
+ Updates the original Markdown document to add tags to associate the document with its corresponding Confluence page.
59
+ """
67
60
 
68
- overwrite = False
69
- if qualified_id is None:
70
- # create new Confluence page
71
- 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:
72
67
  raise PageError(
73
- 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}"
74
69
  )
75
70
 
76
- # assign title from front-matter if present
77
- if title is None:
78
- title, _ = extract_frontmatter_title(text)
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)
76
+ else:
77
+ raise NotImplementedError("condition not exhaustive")
79
78
 
80
- # use file name (without extension) and path hash if no title is supplied
81
- if title is None:
82
- overwrite = True
83
- relative_path = absolute_path.relative_to(self.root_dir)
84
- hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
85
- digest = "".join(f"{c:x}" for c in hash.digest())
86
- title = f"{absolute_path.stem} [{digest}]"
79
+ self._synchronize_subtree(root, real_id)
87
80
 
88
- confluence_page = self._create_page(absolute_path, text, title, parent_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
89
92
  else:
90
- # 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
- )
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
+ )
98
106
 
99
- return ConfluencePageMetadata(
100
- page_id=confluence_page.id,
107
+ data = ConfluencePageMetadata(
108
+ page_id=page.id,
101
109
  space_key=space_key,
102
- title=confluence_page.title,
103
- overwrite=overwrite,
110
+ title=page.title,
104
111
  )
112
+ self.page_metadata.add(node.absolute_path, data)
105
113
 
106
- def _create_page(
107
- self,
108
- absolute_path: Path,
109
- document: str,
110
- title: str,
111
- parent_id: ConfluencePageID,
112
- ) -> ConfluencePage:
113
- """
114
- Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
115
- """
116
-
117
- confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
118
- self._update_markdown(
119
- absolute_path,
120
- document,
121
- confluence_page.id,
122
- self.api.space_id_to_key(confluence_page.space_id),
123
- )
124
- return confluence_page
114
+ for child_node in node.children():
115
+ self._synchronize_subtree(child_node, ConfluencePageID(page.id))
125
116
 
126
- def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
117
+ @override
118
+ def _update_page(
119
+ self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path
120
+ ) -> None:
127
121
  """
128
122
  Saves a new version of a Confluence document.
129
123
 
@@ -133,37 +127,60 @@ class SynchronizingProcessor(Processor):
133
127
  base_path = path.parent
134
128
  for image in document.images:
135
129
  self.api.upload_attachment(
136
- document.id.page_id,
130
+ page_id.page_id,
137
131
  attachment_name(image),
138
132
  attachment_path=base_path / image,
139
133
  )
140
134
 
141
135
  for name, data in document.embedded_images.items():
142
136
  self.api.upload_attachment(
143
- document.id.page_id,
137
+ page_id.page_id,
144
138
  name,
145
139
  raw_data=data,
146
140
  )
147
141
 
148
142
  content = document.xhtml()
143
+ LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
149
144
 
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
145
+ title = None
146
+ if document.title is not None:
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
+ ):
153
+ conflicting_page_id = self.api.page_exists(
154
+ document.title, space_id=self.api.space_key_to_id(meta.space_key)
155
+ )
156
+ if conflicting_page_id is None:
157
+ title = document.title
158
+ else:
159
+ LOGGER.info(
160
+ "Document title of %s conflicts with Confluence page title of %s",
161
+ path,
162
+ conflicting_page_id,
163
+ )
164
+
165
+ self.api.update_page(page_id.page_id, content, title=title)
166
+
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
+ )
152
175
 
153
- LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
154
- self.api.update_page(document.id.page_id, content, title=title)
155
-
156
- def _update_markdown(
157
- self,
158
- path: Path,
159
- document: str,
160
- page_id: str,
161
- space_key: Optional[str],
162
- ) -> None:
176
+ def _update_markdown(self, path: Path, *, page_id: str, space_key: str) -> None:
163
177
  """
164
178
  Writes the Confluence page ID and space key at the beginning of the Markdown file.
165
179
  """
166
180
 
181
+ with open(path, "r", encoding="utf-8") as file:
182
+ document = file.read()
183
+
167
184
  content: list[str] = []
168
185
 
169
186
  # check if the file has frontmatter
@@ -175,9 +192,7 @@ class SynchronizingProcessor(Processor):
175
192
  content.append(document[:index])
176
193
 
177
194
  content.append(f"<!-- confluence-page-id: {page_id} -->")
178
- if space_key:
179
- content.append(f"<!-- confluence-space-key: {space_key} -->")
180
-
195
+ content.append(f"<!-- confluence-space-key: {space_key} -->")
181
196
  content.append(document[index:])
182
197
 
183
198
  with open(path, "w", encoding="utf-8") as file:
@@ -200,6 +215,8 @@ class SynchronizingProcessorFactory(ProcessorFactory):
200
215
  class Application(Converter):
201
216
  """
202
217
  The entry point for Markdown to Confluence conversion.
218
+
219
+ This is the class instantiated by the command-line application.
203
220
  """
204
221
 
205
222
  def __init__(
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
@@ -18,16 +18,17 @@ import xml.etree.ElementTree
18
18
  from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
  from typing import Any, Literal, Optional, Union
21
- from urllib.parse import ParseResult, urlparse, urlunparse
21
+ from urllib.parse import ParseResult, quote_plus, urlparse, urlunparse
22
22
 
23
23
  import lxml.etree as ET
24
24
  import markdown
25
- import yaml
26
25
  from lxml.builder import ElementMaker
27
26
 
27
+ from .collection import ConfluencePageCollection
28
28
  from .mermaid import render_diagram
29
- from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
29
+ from .metadata import ConfluenceSiteMetadata
30
30
  from .properties import PageError
31
+ from .scanner import ScannedDocument, Scanner
31
32
 
32
33
  namespaces = {
33
34
  "ac": "http://atlassian.com/content",
@@ -66,6 +67,19 @@ def is_relative_url(url: str) -> bool:
66
67
  return not bool(urlparts.scheme) and not bool(urlparts.netloc)
67
68
 
68
69
 
70
+ def encode_title(text: str) -> str:
71
+ "Converts a title string such that it is safe to embed into a Confluence URL."
72
+
73
+ # replace unsafe characters with space
74
+ text = re.sub(r"[^A-Za-z0-9._~()'!*:@,;+?-]+", " ", text)
75
+
76
+ # replace multiple consecutive spaces with single space
77
+ text = re.sub(r"\s\s+", " ", text)
78
+
79
+ # URL-encode
80
+ return quote_plus(text.strip())
81
+
82
+
69
83
  def emoji_generator(
70
84
  index: str,
71
85
  shortname: str,
@@ -78,8 +92,10 @@ def emoji_generator(
78
92
  md: markdown.Markdown,
79
93
  ) -> xml.etree.ElementTree.Element:
80
94
  name = (alias or shortname).strip(":")
81
- span = xml.etree.ElementTree.Element("span", {"data-emoji": name})
95
+ span = xml.etree.ElementTree.Element("span", {"data-emoji-shortname": name})
82
96
  if uc is not None:
97
+ span.attrib["data-emoji-unicode"] = uc
98
+
83
99
  # convert series of Unicode code point hexadecimal values into characters
84
100
  span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
85
101
  else:
@@ -349,7 +365,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
349
365
  images: list[Path]
350
366
  embedded_images: dict[str, bytes]
351
367
  site_metadata: ConfluenceSiteMetadata
352
- page_metadata: dict[Path, ConfluencePageMetadata]
368
+ page_metadata: ConfluencePageCollection
353
369
 
354
370
  def __init__(
355
371
  self,
@@ -357,7 +373,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
357
373
  path: Path,
358
374
  root_dir: Path,
359
375
  site_metadata: ConfluenceSiteMetadata,
360
- page_metadata: dict[Path, ConfluencePageMetadata],
376
+ page_metadata: ConfluencePageCollection,
361
377
  ) -> None:
362
378
  super().__init__()
363
379
  self.options = options
@@ -466,7 +482,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
466
482
  "Confluence space key required for building full web URLs"
467
483
  )
468
484
 
469
- page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{link_metadata.title}"
485
+ page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{encode_title(link_metadata.title)}"
470
486
 
471
487
  components = ParseResult(
472
488
  scheme="https",
@@ -821,7 +837,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
821
837
  )
822
838
 
823
839
  def _transform_emoji(self, elem: ET._Element) -> ET._Element:
824
- shortname = elem.attrib.get("data-emoji", "")
840
+ shortname = elem.attrib.get("data-emoji-shortname", "")
841
+ unicode = elem.attrib.get("data-emoji-unicode", None)
825
842
  alt = elem.text or ""
826
843
 
827
844
  # <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="&#128521;"/>
@@ -831,8 +848,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
831
848
  "emoticon",
832
849
  {
833
850
  # use "blue-star" as a placeholder name to ensure wiki page loads in timely manner
834
- ET.QName(namespaces["ac"], "name"): "blue-star",
851
+ ET.QName(namespaces["ac"], "name"): shortname,
835
852
  ET.QName(namespaces["ac"], "emoji-shortname"): f":{shortname}:",
853
+ ET.QName(namespaces["ac"], "emoji-id"): unicode,
836
854
  ET.QName(namespaces["ac"], "emoji-fallback"): alt,
837
855
  },
838
856
  )
@@ -930,7 +948,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
930
948
  elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
931
949
  return self._transform_block(child[0])
932
950
 
933
- elif child.tag == "span" and child.attrib.has_key("data-emoji"):
951
+ elif child.tag == "span" and child.attrib.has_key("data-emoji-shortname"):
934
952
  return self._transform_emoji(child)
935
953
 
936
954
  return None
@@ -949,78 +967,15 @@ class DocumentError(RuntimeError):
949
967
  "Raised when a converted Markdown document has an unexpected element or attribute."
950
968
 
951
969
 
952
- def extract_value(pattern: str, text: str) -> tuple[Optional[str], str]:
953
- values: list[str] = []
954
-
955
- def _repl_func(matchobj: re.Match) -> str:
956
- values.append(matchobj.group(1))
957
- return ""
958
-
959
- text = re.sub(pattern, _repl_func, text, 1, re.ASCII)
960
- value = values[0] if values else None
961
- return value, text
962
-
963
-
964
970
  @dataclass
965
971
  class ConfluencePageID:
966
972
  page_id: str
967
973
 
968
- def __init__(self, page_id: str):
969
- self.page_id = page_id
970
-
971
974
 
972
975
  @dataclass
973
976
  class ConfluenceQualifiedID:
974
977
  page_id: str
975
- space_key: Optional[str] = None
976
-
977
- def __init__(self, page_id: str, space_key: Optional[str] = None):
978
- self.page_id = page_id
979
- self.space_key = space_key
980
-
981
-
982
- def extract_qualified_id(text: str) -> tuple[Optional[ConfluenceQualifiedID], str]:
983
- "Extracts the Confluence page ID and space key from a Markdown document."
984
-
985
- page_id, text = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", text)
986
-
987
- if page_id is None:
988
- return None, text
989
-
990
- # extract Confluence space key
991
- space_key, text = extract_value(r"<!--\s+confluence-space-key:\s*(\S+)\s+-->", text)
992
-
993
- return ConfluenceQualifiedID(page_id, space_key), text
994
-
995
-
996
- def extract_frontmatter(text: str) -> tuple[Optional[str], str]:
997
- "Extracts the front matter from a Markdown document."
998
-
999
- return extract_value(r"(?ms)\A---$(.+?)^---$", text)
1000
-
1001
-
1002
- def extract_frontmatter_title(text: str) -> tuple[Optional[str], str]:
1003
- frontmatter, text = extract_frontmatter(text)
1004
-
1005
- title: Optional[str] = None
1006
- if frontmatter is not None:
1007
- properties = yaml.safe_load(frontmatter)
1008
- if isinstance(properties, dict):
1009
- property_title = properties.get("title")
1010
- if isinstance(property_title, str):
1011
- title = property_title
1012
-
1013
- return title, text
1014
-
1015
-
1016
- def read_qualified_id(absolute_path: Path) -> Optional[ConfluenceQualifiedID]:
1017
- "Reads the Confluence page ID and space key from a Markdown document."
1018
-
1019
- with open(absolute_path, "r", encoding="utf-8") as f:
1020
- document = f.read()
1021
-
1022
- qualified_id, _ = extract_qualified_id(document)
1023
- return qualified_id
978
+ space_key: str
1024
979
 
1025
980
 
1026
981
  @dataclass
@@ -1055,8 +1010,8 @@ class ConversionError(RuntimeError):
1055
1010
 
1056
1011
 
1057
1012
  class ConfluenceDocument:
1058
- id: ConfluenceQualifiedID
1059
1013
  title: Optional[str]
1014
+ labels: Optional[list[str]]
1060
1015
  links: list[str]
1061
1016
  images: list[Path]
1062
1017
 
@@ -1070,64 +1025,48 @@ class ConfluenceDocument:
1070
1025
  options: ConfluenceDocumentOptions,
1071
1026
  root_dir: Path,
1072
1027
  site_metadata: ConfluenceSiteMetadata,
1073
- page_metadata: dict[Path, ConfluencePageMetadata],
1074
- ) -> "ConfluenceDocument":
1028
+ page_metadata: ConfluencePageCollection,
1029
+ ) -> tuple[ConfluencePageID, "ConfluenceDocument"]:
1075
1030
  path = path.resolve(True)
1076
1031
 
1077
- with open(path, "r", encoding="utf-8") as f:
1078
- text = f.read()
1032
+ document = Scanner().read(path)
1079
1033
 
1080
- # extract Confluence page ID
1081
- qualified_id, text = extract_qualified_id(text)
1082
- if qualified_id is None:
1034
+ if document.page_id is not None:
1035
+ page_id = ConfluencePageID(document.page_id)
1036
+ else:
1083
1037
  # look up Confluence page ID in metadata
1084
1038
  metadata = page_metadata.get(path)
1085
1039
  if metadata is not None:
1086
- qualified_id = ConfluenceQualifiedID(
1087
- metadata.page_id, metadata.space_key
1088
- )
1089
- if qualified_id is None:
1090
- raise PageError("missing Confluence page ID")
1040
+ page_id = ConfluencePageID(metadata.page_id)
1041
+ else:
1042
+ raise PageError("missing Confluence page ID")
1091
1043
 
1092
- return ConfluenceDocument(
1093
- path, text, qualified_id, options, root_dir, site_metadata, page_metadata
1044
+ return page_id, ConfluenceDocument(
1045
+ path, document, options, root_dir, site_metadata, page_metadata
1094
1046
  )
1095
1047
 
1096
1048
  def __init__(
1097
1049
  self,
1098
1050
  path: Path,
1099
- text: str,
1100
- qualified_id: ConfluenceQualifiedID,
1051
+ document: ScannedDocument,
1101
1052
  options: ConfluenceDocumentOptions,
1102
1053
  root_dir: Path,
1103
1054
  site_metadata: ConfluenceSiteMetadata,
1104
- page_metadata: dict[Path, ConfluencePageMetadata],
1055
+ page_metadata: ConfluencePageCollection,
1105
1056
  ) -> None:
1106
1057
  self.options = options
1107
- self.id = qualified_id
1108
-
1109
- # extract frontmatter
1110
- self.title, text = extract_frontmatter_title(text)
1111
-
1112
- # extract 'generated-by' tag text
1113
- generated_by_tag, text = extract_value(
1114
- r"<!--\s+generated-by:\s*(.*)\s+-->", text
1115
- )
1116
1058
 
1117
1059
  # convert to HTML
1118
- html = markdown_to_html(text)
1060
+ html = markdown_to_html(document.text)
1119
1061
 
1120
1062
  # parse Markdown document
1121
1063
  if self.options.generated_by is not None:
1122
- if generated_by_tag is not None:
1123
- generated_by_text = generated_by_tag
1124
- else:
1125
- generated_by_text = self.options.generated_by
1064
+ generated_by = document.generated_by or self.options.generated_by
1126
1065
  else:
1127
- generated_by_text = None
1066
+ generated_by = None
1128
1067
 
1129
- if generated_by_text is not None:
1130
- generated_by_html = markdown_to_html(generated_by_text)
1068
+ if generated_by is not None:
1069
+ generated_by_html = markdown_to_html(generated_by)
1131
1070
 
1132
1071
  content = [
1133
1072
  '<ac:structured-macro ac:name="info" ac:schema-version="1">',
@@ -1161,8 +1100,8 @@ class ConfluenceDocument:
1161
1100
  self.images = converter.images
1162
1101
  self.embedded_images = converter.embedded_images
1163
1102
 
1164
- if self.title is None:
1165
- self.title = converter.toc.get_title()
1103
+ self.title = document.title or converter.toc.get_title()
1104
+ self.labels = document.tags
1166
1105
 
1167
1106
  def xhtml(self) -> str:
1168
1107
  return elements_to_string(self.root)
@@ -1214,7 +1153,7 @@ def _content_to_string(dtd_path: Path, content: str) -> str:
1214
1153
 
1215
1154
  data = [
1216
1155
  '<?xml version="1.0"?>',
1217
- 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()}">'
1218
1157
  f"<root{ns_attr_list}>",
1219
1158
  ]
1220
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