markdown-to-confluence 0.2.6__py3-none-any.whl → 0.3.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/converter.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -18,11 +18,12 @@ import uuid
18
18
  import xml.etree.ElementTree
19
19
  from dataclasses import dataclass
20
20
  from pathlib import Path
21
- from typing import Any, Dict, List, Literal, Optional, Tuple, Union
21
+ from typing import Any, Literal, Optional, Union
22
22
  from urllib.parse import ParseResult, urlparse, urlunparse
23
23
 
24
24
  import lxml.etree as ET
25
25
  import markdown
26
+ import yaml
26
27
  from lxml.builder import ElementMaker
27
28
 
28
29
  from . import mermaid
@@ -45,7 +46,7 @@ class ParseError(RuntimeError):
45
46
  pass
46
47
 
47
48
 
48
- def starts_with_any(text: str, prefixes: List[str]) -> bool:
49
+ def starts_with_any(text: str, prefixes: list[str]) -> bool:
49
50
  "True if text starts with any of the listed prefixes."
50
51
 
51
52
  for prefix in prefixes:
@@ -72,7 +73,7 @@ def emoji_generator(
72
73
  alt: str,
73
74
  title: Optional[str],
74
75
  category: Optional[str],
75
- options: Dict[str, Any],
76
+ options: dict[str, Any],
76
77
  md: markdown.Markdown,
77
78
  ) -> xml.etree.ElementTree.Element:
78
79
  name = (alias or shortname).strip(":")
@@ -106,7 +107,7 @@ def markdown_to_html(content: str) -> str:
106
107
  )
107
108
 
108
109
 
109
- def _elements_from_strings(dtd_path: Path, items: List[str]) -> ET._Element:
110
+ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
110
111
  """
111
112
  Creates a fragment of several XML nodes from their string representation wrapped in a root element.
112
113
 
@@ -140,7 +141,7 @@ def _elements_from_strings(dtd_path: Path, items: List[str]) -> ET._Element:
140
141
  raise ParseError(e)
141
142
 
142
143
 
143
- def elements_from_strings(items: List[str]) -> ET._Element:
144
+ def elements_from_strings(items: list[str]) -> ET._Element:
144
145
  "Creates a fragment of several XML nodes from their string representation wrapped in a root element."
145
146
 
146
147
  if sys.version_info >= (3, 9):
@@ -286,7 +287,7 @@ class ConfluenceConverterOptions:
286
287
  conversion rules for the identifier.
287
288
  :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
288
289
  :param diagram_output_format: Target image format for diagrams.
289
- :param web_links: When true, convert relative URLs to Confluence Web UI links.
290
+ :param webui_links: When true, convert relative URLs to Confluence Web UI links.
290
291
  """
291
292
 
292
293
  ignore_invalid_url: bool = False
@@ -303,17 +304,17 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
303
304
  path: Path
304
305
  base_dir: Path
305
306
  root_dir: Path
306
- links: List[str]
307
- images: List[Path]
308
- embedded_images: Dict[str, bytes]
309
- page_metadata: Dict[Path, ConfluencePageMetadata]
307
+ links: list[str]
308
+ images: list[Path]
309
+ embedded_images: dict[str, bytes]
310
+ page_metadata: dict[Path, ConfluencePageMetadata]
310
311
 
311
312
  def __init__(
312
313
  self,
313
314
  options: ConfluenceConverterOptions,
314
315
  path: Path,
315
316
  root_dir: Path,
316
- page_metadata: Dict[Path, ConfluencePageMetadata],
317
+ page_metadata: dict[Path, ConfluencePageMetadata],
317
318
  ) -> None:
318
319
  super().__init__()
319
320
  self.options = options
@@ -350,8 +351,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
350
351
  heading.text = None
351
352
 
352
353
  def _transform_link(self, anchor: ET._Element) -> Optional[ET._Element]:
353
- url = anchor.attrib["href"]
354
- if is_absolute_url(url):
354
+ url = anchor.attrib.get("href")
355
+ if url is None or is_absolute_url(url):
355
356
  return None
356
357
 
357
358
  LOGGER.debug("Found link %s relative to %s", url, self.path)
@@ -432,39 +433,72 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
432
433
  return None
433
434
 
434
435
  def _transform_image(self, image: ET._Element) -> ET._Element:
435
- path: str = image.attrib["src"]
436
+ src = image.attrib.get("src")
436
437
 
437
- if not path:
438
+ if not src:
438
439
  raise DocumentError("image lacks `src` attribute")
439
440
 
440
- if is_absolute_url(path):
441
- # images whose `src` attribute is an absolute URL cannot be converted into an `ac:image`;
442
- # Confluence images are expected to refer to an uploaded attachment
443
- raise DocumentError("image has a `src` attribute that is an absolute URL")
441
+ attributes: dict[str, Any] = {
442
+ ET.QName(namespaces["ac"], "align"): "center",
443
+ ET.QName(namespaces["ac"], "layout"): "center",
444
+ }
445
+ width = image.attrib.get("width")
446
+ if width is not None:
447
+ attributes.update({ET.QName(namespaces["ac"], "width"): width})
448
+ height = image.attrib.get("height")
449
+ if height is not None:
450
+ attributes.update({ET.QName(namespaces["ac"], "height"): height})
451
+
452
+ caption = image.attrib.get("alt")
453
+
454
+ if is_absolute_url(src):
455
+ return self._transform_external_image(src, caption, attributes)
456
+ else:
457
+ return self._transform_attached_image(Path(src), caption, attributes)
444
458
 
445
- relative_path = Path(path)
459
+ def _transform_external_image(
460
+ self, url: str, caption: Optional[str], attributes: dict[str, Any]
461
+ ) -> ET._Element:
462
+ "Emits Confluence Storage Format XHTML for an external image."
463
+
464
+ elements: list[ET._Element] = []
465
+ elements.append(
466
+ RI(
467
+ "url",
468
+ # refers to an external image
469
+ {ET.QName(namespaces["ri"], "value"): url},
470
+ )
471
+ )
472
+ if caption is not None:
473
+ elements.append(AC("caption", HTML.p(caption)))
474
+
475
+ return AC("image", attributes, *elements)
476
+
477
+ def _transform_attached_image(
478
+ self, path: Path, caption: Optional[str], attributes: dict[str, Any]
479
+ ) -> ET._Element:
480
+ "Emits Confluence Storage Format XHTML for an attached image."
446
481
 
447
482
  # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
448
- png_file = relative_path.with_suffix(".png")
449
- if relative_path.suffix == ".svg" and (self.base_dir / png_file).exists():
450
- relative_path = png_file
483
+ png_file = path.with_suffix(".png")
484
+ if path.suffix == ".svg" and (self.base_dir / png_file).exists():
485
+ path = png_file
451
486
 
452
- self.images.append(relative_path)
453
- caption = image.attrib["alt"]
454
- image_name = attachment_name(relative_path)
455
- return AC(
456
- "image",
457
- {
458
- ET.QName(namespaces["ac"], "align"): "center",
459
- ET.QName(namespaces["ac"], "layout"): "center",
460
- },
487
+ self.images.append(path)
488
+ image_name = attachment_name(path)
489
+
490
+ elements: list[ET._Element] = []
491
+ elements.append(
461
492
  RI(
462
493
  "attachment",
463
494
  # refers to an attachment uploaded alongside the page
464
495
  {ET.QName(namespaces["ri"], "filename"): image_name},
465
- ),
466
- AC("caption", HTML.p(caption)),
496
+ )
467
497
  )
498
+ if caption is not None:
499
+ elements.append(AC("caption", HTML.p(caption)))
500
+
501
+ return AC("image", attributes, *elements)
468
502
 
469
503
  def _transform_block(self, code: ET._Element) -> ET._Element:
470
504
  language = code.attrib.get("class")
@@ -491,7 +525,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
491
525
  AC(
492
526
  "parameter",
493
527
  {ET.QName(namespaces["ac"], "name"): "theme"},
494
- "Midnight",
528
+ "Default",
495
529
  ),
496
530
  AC(
497
531
  "parameter",
@@ -865,8 +899,8 @@ class DocumentError(RuntimeError):
865
899
  pass
866
900
 
867
901
 
868
- def extract_value(pattern: str, text: str) -> Tuple[Optional[str], str]:
869
- values: List[str] = []
902
+ def extract_value(pattern: str, text: str) -> tuple[Optional[str], str]:
903
+ values: list[str] = []
870
904
 
871
905
  def _repl_func(matchobj: re.Match) -> str:
872
906
  values.append(matchobj.group(1))
@@ -887,7 +921,7 @@ class ConfluenceQualifiedID:
887
921
  self.space_key = space_key
888
922
 
889
923
 
890
- def extract_qualified_id(text: str) -> Tuple[Optional[ConfluenceQualifiedID], str]:
924
+ def extract_qualified_id(text: str) -> tuple[Optional[ConfluenceQualifiedID], str]:
891
925
  "Extracts the Confluence page ID and space key from a Markdown document."
892
926
 
893
927
  page_id, text = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", text)
@@ -901,12 +935,26 @@ def extract_qualified_id(text: str) -> Tuple[Optional[ConfluenceQualifiedID], st
901
935
  return ConfluenceQualifiedID(page_id, space_key), text
902
936
 
903
937
 
904
- def extract_frontmatter(text: str) -> Tuple[Optional[str], str]:
938
+ def extract_frontmatter(text: str) -> tuple[Optional[str], str]:
905
939
  "Extracts the front matter from a Markdown document."
906
940
 
907
941
  return extract_value(r"(?ms)\A---$(.+?)^---$", text)
908
942
 
909
943
 
944
+ def extract_frontmatter_title(text: str) -> tuple[Optional[str], str]:
945
+ frontmatter, text = extract_frontmatter(text)
946
+
947
+ title: Optional[str] = None
948
+ if frontmatter is not None:
949
+ properties = yaml.safe_load(frontmatter)
950
+ if isinstance(properties, dict):
951
+ property_title = properties.get("title")
952
+ if isinstance(property_title, str):
953
+ title = property_title
954
+
955
+ return title, text
956
+
957
+
910
958
  def read_qualified_id(absolute_path: Path) -> Optional[ConfluenceQualifiedID]:
911
959
  "Reads the Confluence page ID and space key from a Markdown document."
912
960
 
@@ -926,8 +974,8 @@ class ConfluenceDocumentOptions:
926
974
  plain text; when false, raise an exception.
927
975
  :param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
928
976
  conversion rules for the identifier.
929
- :param generated_by: Text to use as the generated-by prompt.
930
- :param show_generated: Whether to display a prompt "This page has been generated with a tool."
977
+ :param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
978
+ :param root_page_id: Confluence page to assume root page role for publishing a directory of Markdown files.
931
979
  :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
932
980
  :param diagram_output_format: Target image format for diagrams.
933
981
  :param webui_links: When true, convert relative URLs to Confluence Web UI links.
@@ -944,8 +992,9 @@ class ConfluenceDocumentOptions:
944
992
 
945
993
  class ConfluenceDocument:
946
994
  id: ConfluenceQualifiedID
947
- links: List[str]
948
- images: List[Path]
995
+ title: Optional[str]
996
+ links: list[str]
997
+ images: list[Path]
949
998
 
950
999
  options: ConfluenceDocumentOptions
951
1000
  root: ET._Element
@@ -955,7 +1004,7 @@ class ConfluenceDocument:
955
1004
  path: Path,
956
1005
  options: ConfluenceDocumentOptions,
957
1006
  root_dir: Path,
958
- page_metadata: Dict[Path, ConfluencePageMetadata],
1007
+ page_metadata: dict[Path, ConfluencePageMetadata],
959
1008
  ) -> None:
960
1009
  self.options = options
961
1010
  path = path.resolve(True)
@@ -982,7 +1031,7 @@ class ConfluenceDocument:
982
1031
  )
983
1032
 
984
1033
  # extract frontmatter
985
- frontmatter, text = extract_frontmatter(text)
1034
+ self.title, text = extract_frontmatter_title(text)
986
1035
 
987
1036
  # convert to HTML
988
1037
  html = markdown_to_html(text)
md2conf/emoji.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
md2conf/matcher.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -10,12 +10,17 @@ import os.path
10
10
  from dataclasses import dataclass
11
11
  from fnmatch import fnmatch
12
12
  from pathlib import Path
13
- from typing import Iterable, List, Optional
13
+ from typing import Iterable, Optional
14
14
 
15
15
 
16
16
  @dataclass
17
17
  class Entry:
18
- "Represents a file or directory entry."
18
+ """
19
+ Represents a file or directory entry.
20
+
21
+ :param name: Name of the file-system entry.
22
+ :param is_dir: True if the entry is a directory.
23
+ """
19
24
 
20
25
  name: str
21
26
  is_dir: bool
@@ -42,7 +47,7 @@ class Matcher:
42
47
  "Compares file and directory names against a list of exclude/include patterns."
43
48
 
44
49
  options: MatcherOptions
45
- rules: List[str]
50
+ rules: list[str]
46
51
 
47
52
  def __init__(self, options: MatcherOptions, directory: Path) -> None:
48
53
  self.options = options
@@ -92,7 +97,7 @@ class Matcher:
92
97
 
93
98
  return not self.is_excluded(name, is_dir)
94
99
 
95
- def filter(self, items: Iterable[Entry]) -> List[Entry]:
100
+ def filter(self, items: Iterable[Entry]) -> list[Entry]:
96
101
  """
97
102
  Returns only those elements from the input that don't match any of the exclusion rules.
98
103
 
@@ -102,7 +107,7 @@ class Matcher:
102
107
 
103
108
  return [item for item in items if self.is_included(item.name, item.is_dir)]
104
109
 
105
- def scandir(self, path: Path) -> List[Entry]:
110
+ def scandir(self, path: Path) -> list[Entry]:
106
111
  """
107
112
  Returns only those entries in a directory whose name doesn't match any of the exclusion rules.
108
113
 
md2conf/mermaid.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
md2conf/processor.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -10,7 +10,7 @@ import hashlib
10
10
  import logging
11
11
  import os
12
12
  from pathlib import Path
13
- from typing import Dict, List, Optional
13
+ from typing import Optional
14
14
 
15
15
  from .converter import (
16
16
  ConfluenceDocument,
@@ -60,7 +60,7 @@ class Processor:
60
60
  LOGGER.info("Synchronizing directory: %s", local_dir)
61
61
 
62
62
  # Step 1: build index of all page metadata
63
- page_metadata: Dict[Path, ConfluencePageMetadata] = {}
63
+ page_metadata: dict[Path, ConfluencePageMetadata] = {}
64
64
  self._index_directory(local_dir, page_metadata)
65
65
  LOGGER.info("Indexed %d page(s)", len(page_metadata))
66
66
 
@@ -83,7 +83,7 @@ class Processor:
83
83
  self,
84
84
  path: Path,
85
85
  root_dir: Path,
86
- page_metadata: Dict[Path, ConfluencePageMetadata],
86
+ page_metadata: dict[Path, ConfluencePageMetadata],
87
87
  ) -> None:
88
88
  "Processes a single Markdown file."
89
89
 
@@ -95,7 +95,7 @@ class Processor:
95
95
  def _index_directory(
96
96
  self,
97
97
  local_dir: Path,
98
- page_metadata: Dict[Path, ConfluencePageMetadata],
98
+ page_metadata: dict[Path, ConfluencePageMetadata],
99
99
  ) -> None:
100
100
  "Indexes Markdown files in a directory recursively."
101
101
 
@@ -103,8 +103,8 @@ class Processor:
103
103
 
104
104
  matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
105
105
 
106
- files: List[Path] = []
107
- directories: List[Path] = []
106
+ files: list[Path] = []
107
+ directories: list[Path] = []
108
108
  for entry in os.scandir(local_dir):
109
109
  if matcher.is_excluded(entry.name, entry.is_dir()):
110
110
  continue
md2conf/properties.py CHANGED
@@ -1,13 +1,13 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
9
  import os
10
- from typing import Dict, Optional
10
+ from typing import Optional
11
11
 
12
12
 
13
13
  class ConfluenceError(RuntimeError):
@@ -20,7 +20,7 @@ class ConfluenceProperties:
20
20
  space_key: str
21
21
  user_name: Optional[str]
22
22
  api_key: str
23
- headers: Optional[Dict[str, str]]
23
+ headers: Optional[dict[str, str]]
24
24
 
25
25
  def __init__(
26
26
  self,
@@ -29,7 +29,7 @@ class ConfluenceProperties:
29
29
  user_name: Optional[str] = None,
30
30
  api_key: Optional[str] = None,
31
31
  space_key: Optional[str] = None,
32
- headers: Optional[Dict[str, str]] = None,
32
+ headers: Optional[dict[str, str]] = None,
33
33
  ) -> None:
34
34
  opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
35
35
  opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
md2conf/util.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -1,21 +0,0 @@
1
- md2conf/__init__.py,sha256=aD2z2fkqyEVbUDQvLSJxfFUOpwMYt5lAZIUAQocULuM,402
2
- md2conf/__main__.py,sha256=6iOI28W_d71tlnCMFpZwvkBmBt5-HazlZsz69gS4Oak,6894
3
- md2conf/api.py,sha256=T-g_VS_cVahcYOs2jBVW38J7MSS94JxzMXlqohd_Sfw,17326
4
- md2conf/application.py,sha256=DSnqBx5hOWWVopnjo1iK_tbQg_7H8MhNPx_SAC3ovXQ,9157
5
- md2conf/converter.py,sha256=5cxxHnI9ux0pi-VW-CArBCGcpMClb8qEJZQd--NyrdY,35042
6
- md2conf/emoji.py,sha256=w9oiOIxzObAE7HTo3f6aETT1_D3t3yZwr88ynU4ENm0,1924
7
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
8
- md2conf/matcher.py,sha256=mYMltZOLypK4O-SJugLgicOwUMem67hiNLg_kPFoJkU,3583
9
- md2conf/mermaid.py,sha256=gqA6Hg6WcPDdR7JOClezAgNZj2Gq4pXJSgmOUlUt6Dk,2192
10
- md2conf/processor.py,sha256=E-Na-a8tNp4CaoRPA5etcXdHXNRdgyMrf6bfKa9P7O4,4781
11
- md2conf/properties.py,sha256=iVIc0h0XtS3Y2LCywX1C9cvmVQ0WljOMt8pl2MDMVCI,1990
12
- md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
13
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- md2conf/util.py,sha256=ftf60MiW7S7rW45ipWX6efP_Sv2F2qpyIDHrGA0cBiw,743
15
- markdown_to_confluence-0.2.6.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
16
- markdown_to_confluence-0.2.6.dist-info/METADATA,sha256=kRQoSGz7LUCqHS7YhX9xsGae9OTql11hMWEnNP0ZRAw,13540
17
- markdown_to_confluence-0.2.6.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
18
- markdown_to_confluence-0.2.6.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
19
- markdown_to_confluence-0.2.6.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
20
- markdown_to_confluence-0.2.6.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
21
- markdown_to_confluence-0.2.6.dist-info/RECORD,,