markdown-to-confluence 0.4.3__py3-none-any.whl → 0.4.5__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/text.py ADDED
@@ -0,0 +1,54 @@
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
+
10
+ def wrap_text(text: str, line_length: int = 160) -> str:
11
+ """
12
+ Wraps text by replacing individual whitespace characters with a linefeed such that lines in the output honor the specified line length.
13
+
14
+ :param text: Input text, optionally with existing UNIX line endings.
15
+ :param line_length: Desired line length.
16
+ :returns: Wrapped output text. Long words that exceed the specified line length are not broken.
17
+ """
18
+
19
+ if line_length < 1:
20
+ raise ValueError("expected: line_length > 0")
21
+
22
+ input = text.encode("utf-8")
23
+ output = bytearray(len(input))
24
+ pos = 0
25
+ length = len(input)
26
+
27
+ while pos < length:
28
+ end = min(pos + line_length, length)
29
+
30
+ # find any linefeed already in input
31
+ left = pos
32
+ while left < end and input[left] != 0x0A:
33
+ left += 1
34
+ if left != end:
35
+ output[pos : left + 1] = input[pos : left + 1]
36
+ pos = left + 1 # include linefeed
37
+ continue
38
+
39
+ # find the nearest whitespace before end of line
40
+ right = end
41
+ while right > pos and input[right - 1] not in b"\t\v\f\r ":
42
+ right -= 1
43
+
44
+ if right == pos or end == length:
45
+ # no whitespace found or at end of input; copy the rest
46
+ output[pos:end] = input[pos:end]
47
+ pos = end
48
+ else:
49
+ # replace the whitespace with a newline
50
+ output[pos : right - 1] = input[pos : right - 1]
51
+ output[right - 1] = 0x0A # linefeed '\n'
52
+ pos = right # skip the whitespace (already replaced)
53
+
54
+ return output.decode("utf-8")
md2conf/toc.py ADDED
@@ -0,0 +1,89 @@
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 dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+
13
+ @dataclass(eq=True)
14
+ class TableOfContentsEntry:
15
+ """
16
+ Represents a table of contents entry.
17
+
18
+ :param level: The heading level assigned to the entry. Each entry can only contain children whose level is strictly greater than of its parent.
19
+ :param text: The heading text.
20
+ :param children: Direct descendants whose parent is this entry.
21
+ """
22
+
23
+ level: int
24
+ text: str
25
+ children: list["TableOfContentsEntry"]
26
+
27
+ def __init__(self, level: int, text: str, children: Optional[list["TableOfContentsEntry"]] = None) -> None:
28
+ self.level = level
29
+ self.text = text
30
+ self.children = children or []
31
+
32
+
33
+ class TableOfContentsBuilder:
34
+ """
35
+ Builds a table of contents from Markdown headings.
36
+ """
37
+
38
+ _root: TableOfContentsEntry
39
+ _stack: list[TableOfContentsEntry]
40
+
41
+ def __init__(self) -> None:
42
+ self._root = TableOfContentsEntry(0, "<root>")
43
+ self._stack = [self._root]
44
+
45
+ @property
46
+ def tree(self) -> list[TableOfContentsEntry]:
47
+ """
48
+ Table of contents as a hierarchy of headings.
49
+ """
50
+
51
+ return self._root.children
52
+
53
+ def add(self, level: int, text: str) -> None:
54
+ """
55
+ Adds a heading to the table of contents.
56
+
57
+ :param level: Markdown heading level (e.g. `1` for first-level heading).
58
+ :param text: Markdown heading text.
59
+ """
60
+
61
+ if level < 1:
62
+ raise ValueError("expected: Markdown heading level >= 1")
63
+
64
+ # remove any stack items deeper than the current level
65
+ top = self._stack[-1]
66
+ while top.level >= level:
67
+ self._stack.pop()
68
+ top = self._stack[-1]
69
+
70
+ # add the new section under the current top level
71
+ item = TableOfContentsEntry(level, text)
72
+ top.children.append(item)
73
+
74
+ # push new level onto the stack
75
+ self._stack.append(item)
76
+
77
+ def get_title(self) -> Optional[str]:
78
+ """
79
+ Returns a proposed document title.
80
+
81
+ The proposed title is text of the top-level heading if and only if that heading is unique.
82
+
83
+ :returns: Title text, or `None` if no title can be inferred.
84
+ """
85
+
86
+ if len(self.tree) == 1:
87
+ return self.tree[0].text
88
+ else:
89
+ return None
md2conf/uri.py ADDED
@@ -0,0 +1,46 @@
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 hashlib
10
+ import urllib.parse
11
+ import uuid
12
+ from urllib.parse import urlparse
13
+
14
+
15
+ def to_data_uri(mime: str, data: str) -> str:
16
+ "Generates a data URI with the specified MIME type."
17
+
18
+ # URL-encode data
19
+ encoded = urllib.parse.quote(data, safe=";/?:@&=+$,-_.!~*'()#") # minimal encoding
20
+ return f"data:{mime},{encoded}"
21
+
22
+
23
+ def to_uuid(data: str) -> uuid.UUID:
24
+ "Generates a UUID that represents the data."
25
+
26
+ # create SHA-1 hash of the SVG content
27
+ sha1_hash = hashlib.sha1(data.encode("utf-8")).digest()
28
+
29
+ # generate UUID using the first 16 bytes of the hash
30
+ return uuid.UUID(bytes=sha1_hash[:16])
31
+
32
+
33
+ def to_uuid_urn(data: str) -> str:
34
+ "Generates a UUID URN that represents the data."
35
+
36
+ return f"urn:uuid:{str(to_uuid(data))}"
37
+
38
+
39
+ def is_absolute_url(url: str) -> bool:
40
+ urlparts = urlparse(url)
41
+ return bool(urlparts.scheme) or bool(urlparts.netloc)
42
+
43
+
44
+ def is_relative_url(url: str) -> bool:
45
+ urlparts = urlparse(url)
46
+ return not bool(urlparts.scheme) and not bool(urlparts.netloc)
md2conf/xml.py CHANGED
@@ -1,11 +1,21 @@
1
- from typing import Iterable, Optional, Union
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 typing import Iterable, Optional
2
10
 
3
11
  import lxml.etree as ET
4
12
 
5
13
 
6
- def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[Union[str, ET.QName]]) -> bool:
14
+ def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[str]) -> bool:
7
15
  """
8
16
  Compares two dictionary objects, excluding keys in the skip set.
17
+
18
+ :param exclude: Attributes to exclude, in `{namespace}name` notation.
9
19
  """
10
20
 
11
21
  # create key sets to compare, excluding keys to be skipped
@@ -23,10 +33,19 @@ def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[
23
33
 
24
34
 
25
35
  class ElementComparator:
26
- skip_attributes: set[Union[str, ET.QName]]
36
+ skip_attributes: set[str]
37
+ skip_elements: set[str]
38
+
39
+ def __init__(self, *, skip_attributes: Optional[Iterable[str]] = None, skip_elements: Optional[Iterable[str]] = None):
40
+ """
41
+ Initializes a new element tree comparator.
42
+
43
+ :param skip_attributes: Attributes to exclude, in `{namespace}name` notation.
44
+ :param skip_elements: Elements to exclude, in `{namespace}name` notation.
45
+ """
27
46
 
28
- def __init__(self, *, skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None):
29
47
  self.skip_attributes = set(skip_attributes) if skip_attributes else set()
48
+ self.skip_elements = set(skip_elements) if skip_elements else set()
30
49
 
31
50
  def is_equal(self, e1: ET._Element, e2: ET._Element) -> bool:
32
51
  """
@@ -36,35 +55,86 @@ class ElementComparator:
36
55
  if e1.tag != e2.tag:
37
56
  return False
38
57
 
39
- e1_text = e1.text.strip() if e1.text else ""
40
- e2_text = e2.text.strip() if e2.text else ""
41
- if e1_text != e2_text:
42
- return False
43
-
58
+ # compare tail first, which is outside of element
44
59
  e1_tail = e1.tail.strip() if e1.tail else ""
45
60
  e2_tail = e2.tail.strip() if e2.tail else ""
46
61
  if e1_tail != e2_tail:
47
62
  return False
48
63
 
64
+ # skip element (and content) if on ignore list
65
+ if e1.tag in self.skip_elements:
66
+ return True
67
+
68
+ # compare text second, which is encapsulated by element
69
+ e1_text = e1.text.strip() if e1.text else ""
70
+ e2_text = e2.text.strip() if e2.text else ""
71
+ if e1_text != e2_text:
72
+ return False
73
+
74
+ # compare attributes, disregarding definition order
49
75
  if not _attrs_equal_excluding(e1.attrib, e2.attrib, self.skip_attributes):
50
76
  return False
77
+
78
+ # compare children recursively
51
79
  if len(e1) != len(e2):
52
80
  return False
53
81
  return all(self.is_equal(c1, c2) for c1, c2 in zip(e1, e2))
54
82
 
55
83
 
56
84
  def is_xml_equal(
57
- tree1: ET._Element,
58
- tree2: ET._Element,
59
- *,
60
- skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None,
85
+ tree1: ET._Element, tree2: ET._Element, *, skip_attributes: Optional[Iterable[str]] = None, skip_elements: Optional[Iterable[str]] = None
61
86
  ) -> bool:
62
87
  """
63
88
  Compare two XML documents for equivalence, ignoring leading/trailing whitespace differences and attribute definition order.
64
89
 
90
+ Elements may be excluded, in which case they compare equal to any element of the same type that has the same tail text.
91
+
65
92
  :param tree1: XML document as an element tree.
66
93
  :param tree2: XML document as an element tree.
94
+ :param skip_attributes: Attributes to exclude, in `{namespace}name` notation.
95
+ :param skip_elements: Elements to exclude, in `{namespace}name` notation.
67
96
  :returns: True if equivalent, False otherwise.
68
97
  """
69
98
 
70
- return ElementComparator(skip_attributes=skip_attributes).is_equal(tree1, tree2)
99
+ return ElementComparator(skip_attributes=skip_attributes, skip_elements=skip_elements).is_equal(tree1, tree2)
100
+
101
+
102
+ def element_to_text(node: ET._Element) -> str:
103
+ "Returns all text contained in an element as a concatenated string."
104
+
105
+ return "".join(node.itertext()).strip()
106
+
107
+
108
+ def unwrap_substitute(name: str, root: ET._Element) -> None:
109
+ """
110
+ Substitutes all occurrences of an element with its contents.
111
+
112
+ :param name: Element tag name to find and replace.
113
+ :param root: Top-most element at which to start.
114
+ """
115
+
116
+ for node in root.iterdescendants(name):
117
+ if node.text:
118
+ # append first piece of text in this element at the end of previous sibling, or text contained by parent
119
+ if (prev_node := node.getprevious()) is not None:
120
+ prev_node.tail = (prev_node.tail or "") + node.text
121
+ elif (parent_node := node.getparent()) is not None: # always true except for root
122
+ parent_node.text = (parent_node.text or "") + node.text
123
+ else:
124
+ raise NotImplementedError("must always have a previous sibling or a parent")
125
+ if node.tail:
126
+ if len(node) > 0:
127
+ # append text immediately following the closing tag of this element to the last child element of this element
128
+ last_node = node[-1]
129
+ last_node.tail = (last_node.tail or "") + node.tail
130
+ else: # node has no child elements, only text
131
+ if (prev_node := node.getprevious()) is not None:
132
+ prev_node.tail = (prev_node.tail or "") + node.tail
133
+ elif (parent_node := node.getparent()) is not None: # always true except for root
134
+ parent_node.text = (parent_node.text or "") + node.tail
135
+ else:
136
+ raise NotImplementedError("must always have a previous sibling or a parent")
137
+ for child in node.iterchildren(reversed=True):
138
+ node.addnext(child)
139
+ if (parent_node := node.getparent()) is not None: # always true except for root
140
+ parent_node.remove(node)
@@ -1,29 +0,0 @@
1
- markdown_to_confluence-0.4.3.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
- md2conf/__init__.py,sha256=ZEoZwOt29zT2OnQNpbYW9lO3zJEJ6soXwwjYX9PwNNo,402
3
- md2conf/__main__.py,sha256=RImfFrO2m9C5iebmBrHKlLjosy_A8AY4O7PK9CmiWSw,11120
4
- md2conf/api.py,sha256=DbG1udDb9ti4OjqgSW3DSuHwxKNFPVDTkhjnaB1GNMI,37193
5
- md2conf/application.py,sha256=MsumqUFw1WPo6-57r06Poq4wg2DPd3hQ4jA5qC4Oios,8212
6
- md2conf/collection.py,sha256=EobgMRJgkYloWlY03NZJ52MRC_SGLpTVCHkltDbQyt0,837
7
- md2conf/converter.py,sha256=mr5UvEhOnM7ZYRIGsgrW85PpxmlpXFjsKYsa8uGFxp0,50475
8
- md2conf/domain.py,sha256=tA9V0vb5Vo9Nt0eQvwAFARaM9TX88LBVQ73nVvdcaqA,1851
9
- md2conf/drawio.py,sha256=P_t7Wp7Tg9XkZM2ZchWCWWEdBaU1KgZ_YX9ZlkZo4Dk,8293
10
- md2conf/emoji.py,sha256=UzDrxqFo59wHmbbJmMNdn0rYFDXbZE4qirOM-_egzXc,2603
11
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
12
- md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
13
- md2conf/local.py,sha256=Cicfp9SJDJuX0aUWZPWCfWKPfQQWxEbifUsmqwxFjDU,3733
14
- md2conf/markdown.py,sha256=9BQbYD4GfpBYmx-3N1M36u2nVWY0VJ9UWKye2Jtnmnk,2901
15
- md2conf/matcher.py,sha256=m5rZjYZSjhKfdeKS8JdPq7cG861Mc6rVZBkrIOZTHGE,6916
16
- md2conf/mermaid.py,sha256=f0x7ISj-41ZMh4zTAFPhIWwr94SDcsVZUc1NWqmH_G4,2508
17
- md2conf/metadata.py,sha256=LzZM-oPNnzCULmLhF516tPlV5zZBknccwMHt8Nan-xg,1007
18
- md2conf/processor.py,sha256=z2d2KMPEYWaxflOtH2UTwrjzpPU8TtLSEUvor85ez1Q,9732
19
- md2conf/properties.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
20
- md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
21
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
- md2conf/scanner.py,sha256=Cyvjab8tBvKgubttQvNagS8nailuTvFBqUGoiX5MNp8,5351
23
- md2conf/xml.py,sha256=HoKJfF1yRZ3Gk8jTS-kRpOqVs0nQJZyr56l0Fo3y9fs,2193
24
- markdown_to_confluence-0.4.3.dist-info/METADATA,sha256=sU45yX796M_cZ0ssGMaDtuxb0xwtKaPTktNAy2rczMg,29119
25
- markdown_to_confluence-0.4.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
- markdown_to_confluence-0.4.3.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
27
- markdown_to_confluence-0.4.3.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
28
- markdown_to_confluence-0.4.3.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
29
- markdown_to_confluence-0.4.3.dist-info/RECORD,,
md2conf/emoji.py DELETED
@@ -1,83 +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 pathlib
10
-
11
- import pymdownx.emoji1_db as emoji_db
12
-
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};"
31
-
32
-
33
- def generate_source(path: pathlib.Path) -> None:
34
- "Generates a source Markdown document for testing emojis."
35
-
36
- emojis = emoji_db.emoji
37
-
38
- with open(path, "w") as f:
39
- print(f"<!-- confluence-page-id: {EMOJI_PAGE_ID} -->", file=f)
40
- print("<!-- This file has been generated by a script. -->", file=f)
41
- print(file=f)
42
- print("## Emoji", file=f)
43
- print(file=f)
44
- print("| Icon | Emoji code |", file=f)
45
- print("| ---- | ---------- |", file=f)
46
- for key in emojis.keys():
47
- key = key.strip(":")
48
- print(f"| :{key}: | `:{key}:` |", file=f)
49
-
50
-
51
- def generate_target(path: pathlib.Path) -> None:
52
- "Generates a target Confluence Storage Format (XML) document for testing emojis."
53
-
54
- emojis = emoji_db.emoji
55
-
56
- with open(path, "w") as f:
57
- print('<ac:structured-macro ac:name="info" ac:schema-version="1">', file=f)
58
- print("<ac:rich-text-body>", file=f)
59
- print("<p>This page has been generated with a tool.</p>", file=f)
60
- print("</ac:rich-text-body>", file=f)
61
- print("</ac:structured-macro>", file=f)
62
- print("<h2>Emoji</h2>", file=f)
63
- print("<table>", file=f)
64
- print("<thead><tr><th>Icon</th><th>Emoji code</th></tr></thead>", file=f)
65
- print("<tbody>", file=f)
66
- for key, data in emojis.items():
67
- unicode = data["unicode"]
68
- key = key.strip(":")
69
- html = "".join(to_html(int(item, base=16)) for item in unicode.split("-"))
70
-
71
- print(
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>",
80
- file=f,
81
- )
82
- print("</tbody>", file=f)
83
- print("</table>", file=f)