markdown-to-confluence 0.4.4__py3-none-any.whl → 0.4.6__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/csf.py CHANGED
@@ -149,3 +149,69 @@ def elements_to_string(root: ET._Element) -> str:
149
149
  return m.group(1)
150
150
  else:
151
151
  raise ValueError("expected: Confluence content")
152
+
153
+
154
+ def is_block_like(elem: ET._Element) -> bool:
155
+ return elem.tag in ["div", "li", "ol", "p", "pre", "td", "th", "ul"]
156
+
157
+
158
+ def normalize_inline(elem: ET._Element) -> None:
159
+ """
160
+ Ensures that inline elements are direct children of an eligible block element.
161
+
162
+ The following transformations are applied:
163
+
164
+ * consecutive inline elements and text nodes that are the direct children of the parent element are wrapped into a `<p>`,
165
+ * block elements are left intact,
166
+ * leading and trailing whitespace in each block element is removed.
167
+
168
+ The above steps transform an element tree such as
169
+ ```
170
+ <li> to <em>be</em>, <ol/> not to <em>be</em> </li>
171
+ ```
172
+
173
+ into another element tree such as
174
+ ```
175
+ <li><p>to <em>be</em>,</p><ol/><p>not to <em>be</em></p></li>
176
+ ```
177
+ """
178
+
179
+ if not is_block_like(elem):
180
+ raise ValueError(f"expected: block element; got: {elem.tag!s}")
181
+
182
+ contents: list[ET._Element] = []
183
+
184
+ paragraph = HTML.p()
185
+ contents.append(paragraph)
186
+ if elem.text:
187
+ paragraph.text = elem.text
188
+ elem.text = None
189
+
190
+ for child in elem:
191
+ if is_block_like(child):
192
+ contents.append(child)
193
+ paragraph = HTML.p()
194
+ contents.append(paragraph)
195
+ if child.tail:
196
+ paragraph.text = child.tail
197
+ child.tail = None
198
+ else:
199
+ paragraph.append(child)
200
+
201
+ for item in contents:
202
+ # remove lead whitespace in the block element
203
+ if item.text:
204
+ item.text = item.text.lstrip()
205
+ if len(item) > 0:
206
+ # remove tail whitespace in the last child of the block element
207
+ last = item[-1]
208
+ if last.tail:
209
+ last.tail = last.tail.rstrip()
210
+ else:
211
+ # remove tail whitespace directly in the block element content
212
+ if item.text:
213
+ item.text = item.text.rstrip()
214
+
215
+ # ignore empty elements
216
+ if item.tag != "p" or len(item) > 0 or item.text:
217
+ elem.append(item)
md2conf/domain.py CHANGED
@@ -30,6 +30,7 @@ class ConfluenceDocumentOptions:
30
30
  :param prefer_raster: Whether to choose PNG files over SVG files when available.
31
31
  :param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
32
32
  :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
33
+ :param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
33
34
  :param diagram_output_format: Target image format for diagrams.
34
35
  :param webui_links: When true, convert relative URLs to Confluence Web UI links.
35
36
  """
@@ -42,5 +43,6 @@ class ConfluenceDocumentOptions:
42
43
  prefer_raster: bool = True
43
44
  render_drawio: bool = False
44
45
  render_mermaid: bool = False
46
+ render_latex: bool = False
45
47
  diagram_output_format: Literal["png", "svg"] = "png"
46
48
  webui_links: bool = False
md2conf/drawio.py CHANGED
@@ -9,9 +9,9 @@ Copyright 2022-2025, Levente Hunyadi
9
9
  import base64
10
10
  import logging
11
11
  import os
12
- import os.path
13
12
  import shutil
14
13
  import subprocess
14
+ import tempfile
15
15
  import typing
16
16
  import zlib
17
17
  from pathlib import Path
@@ -153,7 +153,8 @@ def extract_xml_from_png(png_data: bytes) -> ET._Element:
153
153
  offset += 8
154
154
 
155
155
  if offset + length + 4 > len(png_data):
156
- raise DrawioError(f"corrupted PNG: incomplete data for chunk {chunk_type.decode('ascii')}")
156
+ chunk_name = chunk_type.decode("ascii", errors="replace")
157
+ raise DrawioError(f"corrupted PNG: incomplete data for chunk {chunk_name}")
157
158
 
158
159
  # read chunk data
159
160
  chunk_data = png_data[offset : offset + length]
@@ -169,7 +170,7 @@ def extract_xml_from_png(png_data: bytes) -> ET._Element:
169
170
  # format: keyword\0text
170
171
  null_pos = chunk_data.find(b"\x00")
171
172
  if null_pos < 0:
172
- raise DrawioError("corrupted PNG: tEXt chunk missing keyword")
173
+ raise DrawioError("corrupted PNG: `tEXt` chunk missing keyword or data")
173
174
 
174
175
  keyword = chunk_data[:null_pos].decode("latin1")
175
176
  if keyword != "mxfile":
@@ -236,17 +237,21 @@ def render_diagram(source: Path, output_format: typing.Literal["png", "svg"] = "
236
237
  if executable is None:
237
238
  raise DrawioError("draw.io executable not found")
238
239
 
239
- target = f"tmp_drawio.{output_format}"
240
+ # create a temporary file and get its file descriptor and path
241
+ fd, target = tempfile.mkstemp(prefix="drawio_", suffix=f".{output_format}")
240
242
 
241
- cmd = [executable, "--export", "--format", output_format, "--output", target]
242
- if output_format == "png":
243
- cmd.extend(["--scale", "2", "--transparent"])
244
- elif output_format == "svg":
245
- cmd.append("--embed-svg-images")
246
- cmd.append(str(source))
247
-
248
- LOGGER.debug("Executing: %s", " ".join(cmd))
249
243
  try:
244
+ # close the descriptor, just use the filename
245
+ os.close(fd)
246
+
247
+ cmd = [executable, "--export", "--format", output_format, "--output", target]
248
+ if output_format == "png":
249
+ cmd.extend(["--scale", "2", "--transparent"])
250
+ elif output_format == "svg":
251
+ cmd.append("--embed-svg-images")
252
+ cmd.append(str(source))
253
+
254
+ LOGGER.debug("Executing: %s", " ".join(cmd))
250
255
  proc = subprocess.Popen(
251
256
  cmd,
252
257
  stdout=subprocess.PIPE,
@@ -267,5 +272,4 @@ def render_diagram(source: Path, output_format: typing.Literal["png", "svg"] = "
267
272
  return f.read()
268
273
 
269
274
  finally:
270
- if os.path.exists(target):
271
- os.remove(target)
275
+ os.remove(target)
md2conf/emoticon.py ADDED
@@ -0,0 +1,22 @@
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
+ _EMOJI_TO_EMOTICON = {
10
+ "grinning": "laugh",
11
+ "heart": "heart",
12
+ "slight_frown": "sad",
13
+ "slight_smile": "smile",
14
+ "stuck_out_tongue": "cheeky",
15
+ "thumbsdown": "thumbs-down",
16
+ "thumbsup": "thumbs-up",
17
+ "wink": "wink",
18
+ }
19
+
20
+
21
+ def emoji_to_emoticon(shortname: str) -> str:
22
+ return _EMOJI_TO_EMOTICON.get(shortname) or "blue-star"
md2conf/latex.py ADDED
@@ -0,0 +1,245 @@
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 importlib.util
10
+ from io import BytesIO
11
+ from pathlib import Path
12
+ from struct import unpack
13
+ from typing import BinaryIO, Iterable, Literal, Optional, Union, overload
14
+
15
+
16
+ def render_latex(expression: str, *, format: Literal["png", "svg"] = "png", dpi: int = 100, font_size: int = 12) -> bytes:
17
+ """
18
+ Generates a PNG or SVG image of a LaTeX math expression using `matplotlib` for rendering.
19
+
20
+ :param expression: A LaTeX math expression, e.g., r'\frac{a}{b}'.
21
+ :param format: Output image format.
22
+ :param dpi: Output image resolution (if applicable).
23
+ :param font_size: Font size of the LaTeX text (if applicable).
24
+ """
25
+
26
+ with BytesIO() as f:
27
+ _render_latex(expression, f, format=format, dpi=dpi, font_size=font_size)
28
+ return f.getvalue()
29
+
30
+
31
+ if importlib.util.find_spec("matplotlib") is None:
32
+ LATEX_ENABLED = False
33
+
34
+ def _render_latex(expression: str, f: BinaryIO, *, format: Literal["png", "svg"], dpi: int, font_size: int) -> None:
35
+ raise RuntimeError("matplotlib not installed; run: `pip install matplotlib`")
36
+
37
+ else:
38
+ import matplotlib
39
+ import matplotlib.pyplot as plt
40
+
41
+ matplotlib.rcParams["mathtext.fontset"] = "cm" # change font to "Computer Modern"
42
+
43
+ LATEX_ENABLED = True
44
+
45
+ def _render_latex(expression: str, f: BinaryIO, *, format: Literal["png", "svg"], dpi: int, font_size: int) -> None:
46
+ # create a figure with no axis
47
+ fig = plt.figure(dpi=dpi)
48
+
49
+ # transparent background
50
+ fig.patch.set_alpha(0)
51
+
52
+ # add LaTeX text
53
+ fig.text(x=0, y=0, s=f"${expression}$", fontsize=font_size)
54
+
55
+ # save the image
56
+ fig.savefig(
57
+ f,
58
+ transparent=True,
59
+ format=format,
60
+ bbox_inches="tight",
61
+ pad_inches=0.0,
62
+ metadata={"Title": expression} if format == "png" else None,
63
+ )
64
+
65
+ # close the figure to free memory
66
+ plt.close(fig)
67
+
68
+
69
+ @overload
70
+ def get_png_dimensions(*, data: bytes) -> tuple[int, int]: ...
71
+
72
+
73
+ @overload
74
+ def get_png_dimensions(*, path: Union[str, Path]) -> tuple[int, int]: ...
75
+
76
+
77
+ def get_png_dimensions(*, data: Optional[bytes] = None, path: Union[str, Path, None] = None) -> tuple[int, int]:
78
+ """
79
+ Returns the width and height of a PNG image inspecting its header.
80
+
81
+ :param data: PNG image data.
82
+ :param path: Path to the PNG image file.
83
+ :returns: A tuple of the image's width and height in pixels.
84
+ """
85
+
86
+ if data is not None and path is not None:
87
+ raise TypeError("expected: either `data` or `path`; got: both")
88
+ elif data is not None:
89
+ with BytesIO(data) as f:
90
+ return _get_png_dimensions(f)
91
+ elif path is not None:
92
+ with open(path, "rb") as f:
93
+ return _get_png_dimensions(f)
94
+ else:
95
+ raise TypeError("expected: either `data` or `path`; got: neither")
96
+
97
+
98
+ @overload
99
+ def remove_png_chunks(names: Iterable[str], *, source_data: bytes) -> bytes: ...
100
+
101
+
102
+ @overload
103
+ def remove_png_chunks(names: Iterable[str], *, source_path: Union[str, Path]) -> bytes: ...
104
+
105
+
106
+ @overload
107
+ def remove_png_chunks(names: Iterable[str], *, source_data: bytes, target_path: Union[str, Path]) -> None: ...
108
+
109
+
110
+ @overload
111
+ def remove_png_chunks(names: Iterable[str], *, source_path: Union[str, Path], target_path: Union[str, Path]) -> None: ...
112
+
113
+
114
+ def remove_png_chunks(
115
+ names: Iterable[str], *, source_data: Optional[bytes] = None, source_path: Union[str, Path, None] = None, target_path: Union[str, Path, None] = None
116
+ ) -> Optional[bytes]:
117
+ """
118
+ Rewrites a PNG file by removing chunks with the specified names.
119
+
120
+ :param source_data: PNG image data.
121
+ :param source_path: Path to the file to read from.
122
+ :param target_path: Path to the file to write to.
123
+ """
124
+
125
+ if source_data is not None and source_path is not None:
126
+ raise TypeError("expected: either `source_data` or `source_path`; got: both")
127
+ elif source_data is not None:
128
+
129
+ def source_reader() -> BinaryIO:
130
+ return BytesIO(source_data)
131
+ elif source_path is not None:
132
+
133
+ def source_reader() -> BinaryIO:
134
+ return open(source_path, "rb")
135
+ else:
136
+ raise TypeError("expected: either `source_data` or `source_path`; got: neither")
137
+
138
+ if target_path is None:
139
+ with source_reader() as source_file, BytesIO() as memory_file:
140
+ _remove_png_chunks(names, source_file, memory_file)
141
+ return memory_file.getvalue()
142
+ else:
143
+ with source_reader() as source_file, open(target_path, "wb") as target_file:
144
+ _remove_png_chunks(names, source_file, target_file)
145
+ return None
146
+
147
+
148
+ class _Chunk:
149
+ __slots__ = ("length", "name", "data", "crc")
150
+
151
+ length: int
152
+ name: bytes
153
+ data: bytes
154
+ crc: bytes
155
+
156
+ def __init__(self, length: int, name: bytes, data: bytes, crc: bytes):
157
+ self.length = length
158
+ self.name = name
159
+ self.data = data
160
+ self.crc = crc
161
+
162
+
163
+ def _read_signature(f: BinaryIO) -> None:
164
+ "Reads and checks PNG signature (first 8 bytes)."
165
+
166
+ signature = f.read(8)
167
+ if signature != b"\x89PNG\r\n\x1a\n":
168
+ raise ValueError("not a valid PNG file")
169
+
170
+
171
+ def _read_chunk(f: BinaryIO) -> Optional[_Chunk]:
172
+ "Reads and parses a PNG chunk such as `IHDR` or `tEXt`."
173
+
174
+ length_bytes = f.read(4)
175
+ if not length_bytes:
176
+ return None
177
+
178
+ if len(length_bytes) != 4:
179
+ raise ValueError("insufficient bytes to read chunk length")
180
+
181
+ length = int.from_bytes(length_bytes, "big")
182
+
183
+ data_length = 4 + length + 4
184
+ data_bytes = f.read(data_length)
185
+ if len(data_bytes) != data_length:
186
+ raise ValueError(f"insufficient bytes to read chunk data of length {length}")
187
+
188
+ chunk_type = data_bytes[0:4]
189
+ chunk_data = data_bytes[4:-4]
190
+ crc = data_bytes[-4:]
191
+
192
+ return _Chunk(length, chunk_type, chunk_data, crc)
193
+
194
+
195
+ def _write_chunk(f: BinaryIO, chunk: _Chunk) -> None:
196
+ f.write(chunk.length.to_bytes(4, "big"))
197
+ f.write(chunk.name)
198
+ f.write(chunk.data)
199
+ f.write(chunk.crc)
200
+
201
+
202
+ def _get_png_dimensions(source_file: BinaryIO) -> tuple[int, int]:
203
+ """
204
+ Returns the width and height of a PNG image inspecting its header.
205
+
206
+ :param source_file: A binary file opened for reading that contains PNG image data.
207
+ :returns: A tuple of the image's width and height in pixels.
208
+ """
209
+
210
+ _read_signature(source_file)
211
+
212
+ # validate IHDR chunk
213
+ ihdr = _read_chunk(source_file)
214
+ if ihdr is None:
215
+ raise ValueError("missing IHDR chunk")
216
+
217
+ if ihdr.length != 13:
218
+ raise ValueError("invalid chunk length")
219
+ if ihdr.name != b"IHDR":
220
+ raise ValueError(f"expected: IHDR chunk; got: {ihdr.name!r}")
221
+
222
+ (width, height, bit_depth, color_type, compression, filter, interlace) = unpack(">IIBBBBB", ihdr.data)
223
+ return width, height
224
+
225
+
226
+ def _remove_png_chunks(names: Iterable[str], source_file: BinaryIO, target_file: BinaryIO) -> None:
227
+ """
228
+ Rewrites a PNG file by removing chunks with the specified names.
229
+
230
+ :param source_file: A binary file opened for reading that contains PNG image data.
231
+ :param target_file: A binary file opened for writing to receive PNG image data.
232
+ """
233
+
234
+ exclude_set = set(name.encode("ascii") for name in names)
235
+
236
+ _read_signature(source_file)
237
+ target_file.write(b"\x89PNG\r\n\x1a\n")
238
+
239
+ while True:
240
+ chunk = _read_chunk(source_file)
241
+ if chunk is None:
242
+ break
243
+
244
+ if chunk.name not in exclude_set:
245
+ _write_chunk(target_file, chunk)
md2conf/local.py CHANGED
@@ -83,9 +83,9 @@ class LocalProcessor(Processor):
83
83
  os.makedirs(csf_dir, exist_ok=True)
84
84
  with open(csf_path, "w", encoding="utf-8") as f:
85
85
  f.write(content)
86
- for name, data in document.embedded_files.items():
86
+ for name, file_data in document.embedded_files.items():
87
87
  with open(csf_dir / name, "wb") as f:
88
- f.write(data)
88
+ f.write(file_data.data)
89
89
 
90
90
 
91
91
  class LocalProcessorFactory(ProcessorFactory):
md2conf/markdown.py CHANGED
@@ -76,9 +76,11 @@ _CONVERTER = markdown.Markdown(
76
76
  "markdown.extensions.tables",
77
77
  "md_in_html",
78
78
  "pymdownx.arithmatex",
79
+ "pymdownx.caret",
79
80
  "pymdownx.emoji",
80
81
  "pymdownx.highlight", # required by `pymdownx.superfences`
81
82
  "pymdownx.magiclink",
83
+ "pymdownx.mark",
82
84
  "pymdownx.superfences",
83
85
  "pymdownx.tilde",
84
86
  "sane_lists",
@@ -86,7 +88,7 @@ _CONVERTER = markdown.Markdown(
86
88
  extension_configs={
87
89
  "footnotes": {"BACKLINK_TITLE": ""},
88
90
  "pymdownx.arithmatex": {"generic": True, "preview": False, "tex_inline_wrap": ["", ""], "tex_block_wrap": ["", ""]},
89
- "pymdownx.emoji": {"emoji_generator": _emoji_generator, "strict": True},
91
+ "pymdownx.emoji": {"emoji_generator": _emoji_generator},
90
92
  "pymdownx.highlight": {
91
93
  "use_pygments": False,
92
94
  },
md2conf/mermaid.py CHANGED
@@ -11,11 +11,23 @@ import os
11
11
  import os.path
12
12
  import shutil
13
13
  import subprocess
14
- from typing import Literal
14
+ from dataclasses import dataclass
15
+ from typing import Literal, Optional
15
16
 
16
17
  LOGGER = logging.getLogger(__name__)
17
18
 
18
19
 
20
+ @dataclass
21
+ class MermaidConfigProperties:
22
+ """
23
+ Configuration options for rendering Mermaid diagrams.
24
+
25
+ :param scale: Scaling factor for the rendered diagram.
26
+ """
27
+
28
+ scale: Optional[float] = None
29
+
30
+
19
31
  def is_docker() -> bool:
20
32
  "True if the application is running in a Docker container."
21
33
 
@@ -44,49 +56,46 @@ def has_mmdc() -> bool:
44
56
  return shutil.which(executable) is not None
45
57
 
46
58
 
47
- def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
59
+ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png", config: Optional[MermaidConfigProperties] = None) -> bytes:
48
60
  "Generates a PNG or SVG image from a Mermaid diagram source."
49
61
 
50
- filename = f"tmp_mermaid.{output_format}"
62
+ if config is None:
63
+ config = MermaidConfigProperties()
51
64
 
52
65
  cmd = [
53
66
  get_mmdc(),
54
67
  "--input",
55
68
  "-",
56
69
  "--output",
57
- filename,
70
+ "-",
58
71
  "--outputFormat",
59
72
  output_format,
60
73
  "--backgroundColor",
61
74
  "transparent",
62
75
  "--scale",
63
- "2",
76
+ str(config.scale or 2),
64
77
  ]
65
78
  root = os.path.dirname(__file__)
66
79
  if is_docker():
67
80
  cmd.extend(["-p", os.path.join(root, "puppeteer-config.json")])
68
81
  LOGGER.debug("Executing: %s", " ".join(cmd))
69
- try:
70
- proc = subprocess.Popen(
71
- cmd,
72
- stdout=subprocess.PIPE,
73
- stdin=subprocess.PIPE,
74
- stderr=subprocess.PIPE,
75
- text=False,
76
- )
77
- stdout, stderr = proc.communicate(input=source.encode("utf-8"))
78
- if proc.returncode:
79
- messages = [f"failed to convert Mermaid diagram; exit code: {proc.returncode}"]
80
- console_output = stdout.decode("utf-8")
81
- if console_output:
82
- messages.append(f"output:\n{console_output}")
83
- console_error = stderr.decode("utf-8")
84
- if console_error:
85
- messages.append(f"error:\n{console_error}")
86
- raise RuntimeError("\n".join(messages))
87
- with open(filename, "rb") as image:
88
- return image.read()
89
-
90
- finally:
91
- if os.path.exists(filename):
92
- os.remove(filename)
82
+
83
+ proc = subprocess.Popen(
84
+ cmd,
85
+ stdout=subprocess.PIPE,
86
+ stdin=subprocess.PIPE,
87
+ stderr=subprocess.PIPE,
88
+ text=False,
89
+ )
90
+ stdout, stderr = proc.communicate(input=source.encode("utf-8"))
91
+ if proc.returncode:
92
+ messages = [f"failed to convert Mermaid diagram; exit code: {proc.returncode}"]
93
+ console_output = stdout.decode("utf-8")
94
+ if console_output:
95
+ messages.append(f"output:\n{console_output}")
96
+ console_error = stderr.decode("utf-8")
97
+ if console_error:
98
+ messages.append(f"error:\n{console_error}")
99
+ raise RuntimeError("\n".join(messages))
100
+
101
+ return stdout
md2conf/processor.py CHANGED
@@ -16,9 +16,9 @@ from typing import Iterable, Optional
16
16
  from .collection import ConfluencePageCollection
17
17
  from .converter import ConfluenceDocument
18
18
  from .domain import ConfluenceDocumentOptions, ConfluencePageID
19
+ from .environment import ArgumentError
19
20
  from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
20
21
  from .metadata import ConfluenceSiteMetadata
21
- from .properties import ArgumentError
22
22
  from .scanner import Scanner
23
23
 
24
24
  LOGGER = logging.getLogger(__name__)
@@ -12,13 +12,13 @@ from typing import Optional
12
12
 
13
13
  from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
14
14
  from .converter import ConfluenceDocument, attachment_name, get_volatile_attributes, get_volatile_elements
15
- from .csf import elements_from_string
15
+ from .csf import AC_ATTR, elements_from_string
16
16
  from .domain import ConfluenceDocumentOptions, ConfluencePageID
17
+ from .environment import PageError
17
18
  from .extra import override, path_relative_to
18
19
  from .metadata import ConfluencePageMetadata
19
20
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
20
- from .properties import PageError
21
- from .xml import is_xml_equal
21
+ from .xml import is_xml_equal, unwrap_substitute
22
22
 
23
23
  LOGGER = logging.getLogger(__name__)
24
24
 
@@ -73,20 +73,23 @@ class SynchronizingProcessor(Processor):
73
73
  # verify if page exists
74
74
  page = self.api.get_page_properties(node.page_id)
75
75
  update = False
76
- elif node.title is not None:
77
- # look up page by title
78
- page = self.api.get_or_create_page(node.title, parent_id.page_id)
76
+ else:
77
+ if node.title is not None:
78
+ # use title extracted from source metadata
79
+ title = node.title
80
+ else:
81
+ # assign an auto-generated title
82
+ digest = self._generate_hash(node.absolute_path)
83
+ title = f"{node.absolute_path.stem} [{digest}]"
84
+
85
+ # look up page by (possibly auto-generated) title
86
+ page = self.api.get_or_create_page(title, parent_id.page_id)
79
87
 
80
88
  if page.status is ConfluenceStatus.ARCHIVED:
89
+ # user has archived a page with this (auto-generated) title
81
90
  raise PageError(f"unable to update archived page with ID {page.id}")
82
91
 
83
92
  update = True
84
- else:
85
- # always create a new page
86
- digest = self._generate_hash(node.absolute_path)
87
- title = f"{node.absolute_path.stem} [{digest}]"
88
- page = self.api.create_page(parent_id.page_id, title, "")
89
- update = True
90
93
 
91
94
  space_key = self.api.space_id_to_key(page.spaceId)
92
95
  if update:
@@ -116,18 +119,20 @@ class SynchronizingProcessor(Processor):
116
119
  """
117
120
 
118
121
  base_path = path.parent
119
- for image_path in document.images:
122
+ for image_data in document.images:
120
123
  self.api.upload_attachment(
121
124
  page_id.page_id,
122
- attachment_name(path_relative_to(image_path, base_path)),
123
- attachment_path=image_path,
125
+ attachment_name(path_relative_to(image_data.path, base_path)),
126
+ attachment_path=image_data.path,
127
+ comment=image_data.description,
124
128
  )
125
129
 
126
- for name, data in document.embedded_files.items():
130
+ for name, file_data in document.embedded_files.items():
127
131
  self.api.upload_attachment(
128
132
  page_id.page_id,
129
133
  name,
130
- raw_data=data,
134
+ raw_data=file_data.data,
135
+ comment=file_data.description,
131
136
  )
132
137
 
133
138
  content = document.xhtml()
@@ -152,10 +157,14 @@ class SynchronizingProcessor(Processor):
152
157
  if not title: # empty or `None`
153
158
  title = page.title
154
159
 
160
+ # discard comments
161
+ tree = elements_from_string(page.content)
162
+ unwrap_substitute(AC_ATTR("inline-comment-marker"), tree)
163
+
155
164
  # check if page has any changes
156
165
  if page.title != title or not is_xml_equal(
157
166
  document.root,
158
- elements_from_string(page.content),
167
+ tree,
159
168
  skip_attributes=get_volatile_attributes(),
160
169
  skip_elements=get_volatile_elements(),
161
170
  ):
@@ -209,7 +218,7 @@ class SynchronizingProcessorFactory(ProcessorFactory):
209
218
  return SynchronizingProcessor(self.api, self.options, root_dir)
210
219
 
211
220
 
212
- class Application(Converter):
221
+ class Publisher(Converter):
213
222
  """
214
223
  The entry point for Markdown to Confluence conversion.
215
224