markdown-to-confluence 0.2.0__py3-none-any.whl → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: markdown-to-confluence
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -51,7 +51,7 @@ This Python package
51
51
  * Image references (uploaded as Confluence page attachments)
52
52
  * Tables
53
53
  * [Table of contents](https://docs.gitlab.com/ee/user/markdown.html#table-of-contents)
54
- * [Admonitions](https://python-markdown.github.io/extensions/admonition/) and [alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
54
+ * [Admonitions](https://python-markdown.github.io/extensions/admonition/) and alert boxes in [GitHub](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) and [GitLab](https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes)
55
55
  * [Collapsed sections](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections)
56
56
  * [Mermaid diagrams](https://mermaid.live/) in code blocks (converted to images)
57
57
 
@@ -149,16 +149,17 @@ Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes prece
149
149
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
150
150
 
151
151
  ```sh
152
- $ python3 -m md2conf sample/example.md
152
+ $ python3 -m md2conf sample/index.md
153
153
  ```
154
154
 
155
155
  Use the `--help` switch to get a full list of supported command-line options:
156
156
 
157
157
  ```console
158
158
  $ python3 -m md2conf --help
159
- usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE]
160
- [--generated-by GENERATED_BY] [--no-generated-by] [--render-mermaid] [--no-render-mermaid]
161
- [--render-mermaid-format {png,svg}] [--heading-anchors] [--ignore-invalid-url] [--local]
159
+ usage: md2conf [-h] [--version] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
160
+ [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY] [--no-generated-by]
161
+ [--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors]
162
+ [--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
162
163
  mdpath
163
164
 
164
165
  positional arguments:
@@ -166,6 +167,7 @@ positional arguments:
166
167
 
167
168
  options:
168
169
  -h, --help show this help message and exit
170
+ --version show program's version number and exit
169
171
  -d DOMAIN, --domain DOMAIN
170
172
  Confluence organization domain.
171
173
  -p PATH, --path PATH Base path for Confluence (default: '/wiki/').
@@ -188,6 +190,9 @@ options:
188
190
  --heading-anchors Place an anchor at each section heading with GitHub-style same-page identifiers.
189
191
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
190
192
  --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
193
+ --headers [KEY=VALUE ...]
194
+ Apply custom headers to all Confluence API requests.
195
+ --webui-links Enable Confluence Web UI links.
191
196
  ```
192
197
 
193
198
  ### Using the docker container
@@ -0,0 +1,17 @@
1
+ md2conf/__init__.py,sha256=xyEemQnRFIqHO1wvcc3eovTSr1CDUve26Sq0msXUIZw,402
2
+ md2conf/__main__.py,sha256=_qUspNQmQdhpH4Myh9vXDcauPyUx_FyEzNtaW_c8ytY,6601
3
+ md2conf/api.py,sha256=UZ7mkeE1d_f_bACj8LC-t6d4EqXFQCufbeVVdi4FsTs,16947
4
+ md2conf/application.py,sha256=W5OV86XbHWP1qqmJAcRvHPH7NpKQE6yF6nlGpN6RmoU,7709
5
+ md2conf/converter.py,sha256=_zFk-H4NZuY2Y58enVGgFNubOJv9EI2u8tS7RQRiD3A,30391
6
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
+ md2conf/mermaid.py,sha256=3zawPXHXkCDhEK-WNtCH-gTqsLBDRzLrmlSo8ZW-Ii8,1371
8
+ md2conf/processor.py,sha256=Tx8t7S8Wl1a4rgMvn2-qw8ob9Q5w2L81a0mfqFYmRJg,3963
9
+ md2conf/properties.py,sha256=2l1tW8HmnrEsXN4-Dtby2tYJQTG1MirRpM3H6ykjQ4c,1858
10
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ markdown_to_confluence-0.2.1.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
12
+ markdown_to_confluence-0.2.1.dist-info/METADATA,sha256=AiazDLT-VIO7txFz-lJvvCA0ckVfPNjXNXHV6csWOl4,10422
13
+ markdown_to_confluence-0.2.1.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
14
+ markdown_to_confluence-0.2.1.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
15
+ markdown_to_confluence-0.2.1.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
16
+ markdown_to_confluence-0.2.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
17
+ markdown_to_confluence-0.2.1.dist-info/RECORD,,
md2conf/__init__.py CHANGED
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.2.1"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2024, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -2,11 +2,13 @@ import argparse
2
2
  import logging
3
3
  import os.path
4
4
  import sys
5
+ import typing
5
6
  from pathlib import Path
6
- from typing import Optional
7
+ from typing import Any, Literal, Optional, Sequence, Union
7
8
 
8
9
  import requests
9
10
 
11
+ from . import __version__
10
12
  from .api import ConfluenceAPI
11
13
  from .application import Application
12
14
  from .converter import ConfluenceDocumentOptions
@@ -24,12 +26,37 @@ class Arguments(argparse.Namespace):
24
26
  loglevel: str
25
27
  ignore_invalid_url: bool
26
28
  heading_anchors: bool
29
+ root_page: Optional[str]
27
30
  generated_by: Optional[str]
31
+ render_mermaid: bool
32
+ diagram_output_format: Literal["png", "svg"]
33
+ webui_links: bool
34
+
35
+
36
+ class KwargsAppendAction(argparse.Action):
37
+ """Append key-value pairs to a dictionary"""
38
+
39
+ def __call__(
40
+ self,
41
+ parser: argparse.ArgumentParser,
42
+ namespace: argparse.Namespace,
43
+ values: Union[None, str, Sequence[Any]],
44
+ option_string: Optional[str] = None,
45
+ ) -> None:
46
+ try:
47
+ d = dict(map(lambda x: x.split("="), typing.cast(Sequence[str], values)))
48
+ except ValueError:
49
+ raise argparse.ArgumentError(
50
+ self,
51
+ f'Could not parse argument "{values}". It should follow the format: k1=v1 k2=v2 ...',
52
+ )
53
+ setattr(namespace, self.dest, d)
28
54
 
29
55
 
30
56
  def main() -> None:
31
57
  parser = argparse.ArgumentParser()
32
58
  parser.prog = os.path.basename(os.path.dirname(__file__))
59
+ parser.add_argument("--version", action="version", version=__version__)
33
60
  parser.add_argument(
34
61
  "mdpath", help="Path to Markdown file or directory to convert and publish."
35
62
  )
@@ -119,6 +146,20 @@ def main() -> None:
119
146
  default=False,
120
147
  help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
121
148
  )
149
+ parser.add_argument(
150
+ "--headers",
151
+ nargs="*",
152
+ required=False,
153
+ action=KwargsAppendAction,
154
+ metavar="KEY=VALUE",
155
+ help="Apply custom headers to all Confluence API requests.",
156
+ )
157
+ parser.add_argument(
158
+ "--webui-links",
159
+ action="store_true",
160
+ default=False,
161
+ help="Enable Confluence Web UI links.",
162
+ )
122
163
 
123
164
  args = Arguments()
124
165
  parser.parse_args(namespace=args)
@@ -139,9 +180,10 @@ def main() -> None:
139
180
  root_page_id=args.root_page,
140
181
  render_mermaid=args.render_mermaid,
141
182
  diagram_output_format=args.diagram_output_format,
183
+ webui_links=args.webui_links,
142
184
  )
143
185
  properties = ConfluenceProperties(
144
- args.domain, args.path, args.username, args.apikey, args.space
186
+ args.domain, args.path, args.username, args.apikey, args.space, args.headers
145
187
  )
146
188
  if args.local:
147
189
  Processor(options, properties).process(args.mdpath)
md2conf/api.py CHANGED
@@ -98,6 +98,10 @@ class ConfluenceAPI:
98
98
  session.headers.update(
99
99
  {"Authorization": f"Bearer {self.properties.api_key}"}
100
100
  )
101
+
102
+ if self.properties.headers:
103
+ session.headers.update(self.properties.headers)
104
+
101
105
  self.session = ConfluenceSession(
102
106
  session,
103
107
  self.properties.domain,
@@ -352,6 +356,34 @@ class ConfluenceSession:
352
356
  content=typing.cast(str, storage["value"]),
353
357
  )
354
358
 
359
+ def get_page_ancestors(
360
+ self, page_id: str, *, space_key: Optional[str] = None
361
+ ) -> Dict[str, str]:
362
+ """
363
+ Retrieve Confluence wiki page ancestors.
364
+
365
+ :param page_id: The Confluence page ID.
366
+ :param space_key: The Confluence space key (unless the default space is to be used).
367
+ :returns: Dictionary of ancestor page ID to title, with topmost ancestor first.
368
+ """
369
+
370
+ path = f"/content/{page_id}"
371
+ query = {
372
+ "spaceKey": space_key or self.space_key,
373
+ "expand": "ancestors",
374
+ }
375
+ data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
376
+ ancestors = typing.cast(List[JsonType], data["ancestors"])
377
+
378
+ # from the JSON array of ancestors, extract the "id" and "title"
379
+ results: Dict[str, str] = {}
380
+ for node in ancestors:
381
+ ancestor = typing.cast(Dict[str, JsonType], node)
382
+ id = typing.cast(str, ancestor["id"])
383
+ title = typing.cast(str, ancestor["title"])
384
+ results[id] = title
385
+ return results
386
+
355
387
  def get_page_version(
356
388
  self,
357
389
  page_id: str,
md2conf/application.py CHANGED
@@ -1,15 +1,17 @@
1
1
  import logging
2
2
  import os.path
3
3
  from pathlib import Path
4
- from typing import Dict, Optional
4
+ from typing import Dict, List, Optional
5
5
 
6
- from .api import ConfluenceSession
6
+ from .api import ConfluencePage, ConfluenceSession
7
7
  from .converter import (
8
8
  ConfluenceDocument,
9
9
  ConfluenceDocumentOptions,
10
10
  ConfluencePageMetadata,
11
+ ConfluenceQualifiedID,
11
12
  attachment_name,
12
13
  extract_qualified_id,
14
+ read_qualified_id,
13
15
  )
14
16
 
15
17
  LOGGER = logging.getLogger(__name__)
@@ -45,28 +47,19 @@ class Application:
45
47
  def synchronize_directory(self, local_dir: Path) -> None:
46
48
  "Synchronizes a directory of Markdown pages with Confluence."
47
49
 
48
- page_metadata: Dict[Path, ConfluencePageMetadata] = {}
49
50
  LOGGER.info(f"Synchronizing directory: {local_dir}")
50
51
 
51
52
  # Step 1: build index of all page metadata
52
- # NOTE: Pathlib.walk() is implemented only in Python 3.12+
53
- # so sticking for old os.walk
54
- for root, directories, files in os.walk(local_dir):
55
- for file_name in files:
56
- # Reconstitute Path object back
57
- docfile = (Path(root) / file_name).absolute()
58
-
59
- # Skip non-markdown files
60
- if docfile.suffix.lower() != ".md":
61
- continue
62
- metadata = self._get_or_create_page(docfile)
63
-
64
- LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
65
- page_metadata[docfile] = metadata
66
-
67
- LOGGER.info(f"indexed {len(page_metadata)} pages")
53
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
54
+ root_id = (
55
+ ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
56
+ if self.options.root_page_id
57
+ else None
58
+ )
59
+ self._index_directory(local_dir, root_id, page_metadata)
60
+ LOGGER.info(f"indexed {len(page_metadata)} page(s)")
68
61
 
69
- # Step 2: Convert each page
62
+ # Step 2: convert each page
70
63
  for page_path in page_metadata.keys():
71
64
  self._synchronize_page(page_path, page_metadata)
72
65
 
@@ -86,8 +79,51 @@ class Application:
86
79
  else:
87
80
  self._update_document(document, base_path)
88
81
 
82
+ def _index_directory(
83
+ self,
84
+ local_dir: Path,
85
+ root_id: Optional[ConfluenceQualifiedID],
86
+ page_metadata: Dict[Path, ConfluencePageMetadata],
87
+ ) -> None:
88
+ "Indexes Markdown files in a directory recursively."
89
+
90
+ LOGGER.info(f"Indexing directory: {local_dir}")
91
+
92
+ files: List[Path] = []
93
+ directories: List[Path] = []
94
+ for entry in os.scandir(local_dir):
95
+ if entry.is_file():
96
+ if entry.name.endswith(".md"):
97
+ # skip non-markdown files
98
+ files.append((Path(local_dir) / entry.name).absolute())
99
+ elif entry.is_dir():
100
+ if not entry.name.startswith("."):
101
+ directories.append((Path(local_dir) / entry.name).absolute())
102
+
103
+ # make page act as parent node in Confluence
104
+ parent_id: Optional[ConfluenceQualifiedID] = None
105
+ if "index.md" in files:
106
+ parent_id = read_qualified_id(Path(local_dir) / "index.md")
107
+ elif "README.md" in files:
108
+ parent_id = read_qualified_id(Path(local_dir) / "README.md")
109
+
110
+ if parent_id is None:
111
+ parent_id = root_id
112
+
113
+ for doc in files:
114
+ metadata = self._get_or_create_page(doc, parent_id)
115
+ LOGGER.debug(f"indexed {doc} with metadata: {metadata}")
116
+ page_metadata[doc] = metadata
117
+
118
+ for directory in directories:
119
+ self._index_directory(Path(local_dir) / directory, parent_id, page_metadata)
120
+
89
121
  def _get_or_create_page(
90
- self, absolute_path: Path, title: Optional[str] = None
122
+ self,
123
+ absolute_path: Path,
124
+ parent_id: Optional[ConfluenceQualifiedID],
125
+ *,
126
+ title: Optional[str] = None,
91
127
  ) -> ConfluencePageMetadata:
92
128
  """
93
129
  Creates a new Confluence page if no page is linked in the Markdown document.
@@ -103,23 +139,13 @@ class Application:
103
139
  qualified_id.page_id, space_key=qualified_id.space_key
104
140
  )
105
141
  else:
106
- if self.options.root_page_id is None:
142
+ if parent_id is None:
107
143
  raise ValueError(
108
144
  "expected: Confluence page ID to act as parent for Markdown files with no linked Confluence page"
109
145
  )
110
146
 
111
- # use file name without extension if no title is supplied
112
- if title is None:
113
- title = absolute_path.stem
114
-
115
- confluence_page = self.api.get_or_create_page(
116
- title, self.options.root_page_id
117
- )
118
- self._update_markdown(
119
- absolute_path,
120
- document,
121
- confluence_page.id,
122
- confluence_page.space_key,
147
+ confluence_page = self._create_page(
148
+ absolute_path, document, title, parent_id
123
149
  )
124
150
 
125
151
  return ConfluencePageMetadata(
@@ -130,7 +156,32 @@ class Application:
130
156
  title=confluence_page.title or "",
131
157
  )
132
158
 
159
+ def _create_page(
160
+ self,
161
+ absolute_path: Path,
162
+ document: str,
163
+ title: Optional[str],
164
+ parent_id: ConfluenceQualifiedID,
165
+ ) -> ConfluencePage:
166
+ "Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet."
167
+
168
+ # use file name without extension if no title is supplied
169
+ if title is None:
170
+ title = absolute_path.stem
171
+
172
+ confluence_page = self.api.get_or_create_page(
173
+ title, parent_id.page_id, space_key=parent_id.space_key
174
+ )
175
+ self._update_markdown(
176
+ absolute_path,
177
+ document,
178
+ confluence_page.id,
179
+ confluence_page.space_key,
180
+ )
181
+ return confluence_page
182
+
133
183
  def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
184
+ "Saves a new version of a Confluence document."
134
185
 
135
186
  for image in document.images:
136
187
  self.api.upload_attachment(
@@ -158,8 +209,23 @@ class Application:
158
209
  page_id: str,
159
210
  space_key: Optional[str],
160
211
  ) -> None:
212
+ "Writes the Confluence page ID and space key at the beginning of the Markdown file."
213
+
214
+ content: List[str] = []
215
+
216
+ # check if the file has frontmatter
217
+ index = 0
218
+ if document.startswith("---\n"):
219
+ index = document.find("\n---\n", 4) + 4
220
+
221
+ # insert the Confluence keys after the frontmatter
222
+ content.append(document[:index])
223
+
224
+ content.append(f"<!-- confluence-page-id: {page_id} -->")
225
+ if space_key:
226
+ content.append(f"<!-- confluence-space-key: {space_key} -->")
227
+
228
+ content.append(document[index:])
229
+
161
230
  with open(path, "w", encoding="utf-8") as file:
162
- file.write(f"<!-- confluence-page-id: {page_id} -->\n")
163
- if space_key:
164
- file.write(f"<!-- confluence-space-key: {space_key} -->\n")
165
- file.write(document)
231
+ file.write("\n".join(content))
md2conf/converter.py CHANGED
@@ -4,11 +4,11 @@ import hashlib
4
4
  import importlib.resources as resources
5
5
  import logging
6
6
  import os.path
7
- import pathlib
8
7
  import re
9
8
  import sys
10
9
  import uuid
11
10
  from dataclasses import dataclass
11
+ from pathlib import Path
12
12
  from typing import Dict, List, Literal, Optional, Tuple
13
13
  from urllib.parse import ParseResult, urlparse, urlunparse
14
14
 
@@ -36,6 +36,15 @@ class ParseError(RuntimeError):
36
36
  pass
37
37
 
38
38
 
39
+ def starts_with_any(text: str, prefixes: List[str]) -> bool:
40
+ "True if text starts with any of the listed prefixes."
41
+
42
+ for prefix in prefixes:
43
+ if text.startswith(prefix):
44
+ return True
45
+ return False
46
+
47
+
39
48
  def is_absolute_url(url: str) -> bool:
40
49
  urlparts = urlparse(url)
41
50
  return bool(urlparts.scheme) or bool(urlparts.netloc)
@@ -61,7 +70,7 @@ def markdown_to_html(content: str) -> str:
61
70
  )
62
71
 
63
72
 
64
- def _elements_from_strings(dtd_path: pathlib.Path, items: List[str]) -> ET._Element:
73
+ def _elements_from_strings(dtd_path: Path, items: List[str]) -> ET._Element:
65
74
  """
66
75
  Creates a fragment of several XML nodes from their string representation wrapped in a root element.
67
76
 
@@ -240,30 +249,32 @@ class ConfluenceConverterOptions:
240
249
  conversion rules for the identifier.
241
250
  :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
242
251
  :param diagram_output_format: Target image format for diagrams.
252
+ :param web_links: When true, convert relative URLs to Confluence Web UI links.
243
253
  """
244
254
 
245
255
  ignore_invalid_url: bool = False
246
256
  heading_anchors: bool = False
247
257
  render_mermaid: bool = False
248
258
  diagram_output_format: Literal["png", "svg"] = "png"
259
+ webui_links: bool = False
249
260
 
250
261
 
251
262
  class ConfluenceStorageFormatConverter(NodeVisitor):
252
263
  "Transforms a plain HTML tree into the Confluence storage format."
253
264
 
254
265
  options: ConfluenceConverterOptions
255
- path: pathlib.Path
256
- base_path: pathlib.Path
266
+ path: Path
267
+ base_path: Path
257
268
  links: List[str]
258
269
  images: List[str]
259
270
  embedded_images: Dict[str, bytes]
260
- page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
271
+ page_metadata: Dict[Path, ConfluencePageMetadata]
261
272
 
262
273
  def __init__(
263
274
  self,
264
275
  options: ConfluenceConverterOptions,
265
- path: pathlib.Path,
266
- page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
276
+ path: Path,
277
+ page_metadata: Dict[Path, ConfluencePageMetadata],
267
278
  ) -> None:
268
279
  super().__init__()
269
280
  self.options = options
@@ -347,10 +358,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
347
358
  )
348
359
  self.links.append(url)
349
360
 
361
+ if self.options.webui_links:
362
+ page_url = f"{link_metadata.base_path}pages/viewpage.action?pageId={link_metadata.page_id}"
363
+ else:
364
+ page_url = f"{link_metadata.base_path}spaces/{link_metadata.space_key}/pages/{link_metadata.page_id}/{link_metadata.title}"
365
+
350
366
  components = ParseResult(
351
367
  scheme="https",
352
368
  netloc=link_metadata.domain,
353
- path=f"{link_metadata.base_path}spaces/{link_metadata.space_key}/pages/{link_metadata.page_id}/{link_metadata.title}",
369
+ path=page_url,
354
370
  params="",
355
371
  query="",
356
372
  fragment=relative_url.fragment,
@@ -365,7 +381,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
365
381
 
366
382
  # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
367
383
  if path and is_relative_url(path):
368
- relative_path = pathlib.Path(path)
384
+ relative_path = Path(path)
369
385
  if (
370
386
  relative_path.suffix == ".svg"
371
387
  and (self.base_path / relative_path.with_suffix(".png")).exists()
@@ -541,43 +557,83 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
541
557
  *content,
542
558
  )
543
559
 
544
- def _transform_alert(self, elem: ET._Element) -> ET._Element:
560
+ def _transform_github_alert(self, elem: ET._Element) -> ET._Element:
561
+ content = elem[0]
562
+ if content.text is None:
563
+ raise DocumentError("empty content")
564
+
565
+ class_name: Optional[str] = None
566
+ skip = 0
567
+
568
+ pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
569
+ match = pattern.match(content.text)
570
+ if match:
571
+ skip = len(match.group(0))
572
+ alert = match.group(1)
573
+ if alert == "NOTE":
574
+ class_name = "note"
575
+ elif alert == "TIP":
576
+ class_name = "tip"
577
+ elif alert == "IMPORTANT":
578
+ class_name = "tip"
579
+ elif alert == "WARNING":
580
+ class_name = "warning"
581
+ elif alert == "CAUTION":
582
+ class_name = "warning"
583
+ else:
584
+ raise DocumentError(f"unsupported GitHub alert: {alert}")
585
+
586
+ return self._transform_alert(elem, class_name, skip)
587
+
588
+ def _transform_gitlab_alert(self, elem: ET._Element) -> ET._Element:
589
+ content = elem[0]
590
+ if content.text is None:
591
+ raise DocumentError("empty content")
592
+
593
+ class_name: Optional[str] = None
594
+ skip = 0
595
+
596
+ pattern = re.compile(r"^(FLAG|NOTE|WARNING|DISCLAIMER):\s*")
597
+ match = pattern.match(content.text)
598
+ if match:
599
+ skip = len(match.group(0))
600
+ alert = match.group(1)
601
+ if alert == "FLAG":
602
+ class_name = "note"
603
+ elif alert == "NOTE":
604
+ class_name = "note"
605
+ elif alert == "WARNING":
606
+ class_name = "warning"
607
+ elif alert == "DISCLAIMER":
608
+ class_name = "info"
609
+ else:
610
+ raise DocumentError(f"unsupported GitLab alert: {alert}")
611
+
612
+ return self._transform_alert(elem, class_name, skip)
613
+
614
+ def _transform_alert(
615
+ self, elem: ET._Element, class_name: Optional[str], skip: int
616
+ ) -> ET._Element:
545
617
  """
546
- Creates an info, tip, note or warning panel from a GitHub alert.
618
+ Creates an info, tip, note or warning panel from a GitHub or GitLab alert.
547
619
 
548
620
  Transforms
549
- [GitHub alert](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) # noqa: E501 # no way to make this link shorter
621
+ [GitHub alert](https://docs.github.com/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
622
+ or [GitLab alert](https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes)
550
623
  syntax into one of the Confluence structured macros *info*, *tip*, *note*, or *warning*.
551
624
  """
552
625
 
553
- pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
554
-
555
626
  content = elem[0]
556
627
  if content.text is None:
557
628
  raise DocumentError("empty content")
558
629
 
559
- match = pattern.match(content.text)
560
- if match is None:
630
+ if class_name is None:
561
631
  raise DocumentError("not an alert")
562
- alert = match.group(1)
563
-
564
- if alert == "NOTE":
565
- class_name = "note"
566
- elif alert == "TIP":
567
- class_name = "tip"
568
- elif alert == "IMPORTANT":
569
- class_name = "tip"
570
- elif alert == "WARNING":
571
- class_name = "warning"
572
- elif alert == "CAUTION":
573
- class_name = "warning"
574
- else:
575
- raise DocumentError(f"unsupported alert: {alert}")
576
632
 
577
633
  for e in elem:
578
634
  self.visit(e)
579
635
 
580
- content.text = pattern.sub("", content.text, count=1)
636
+ content.text = content.text[skip:]
581
637
  return AC(
582
638
  "structured-macro",
583
639
  {
@@ -671,7 +727,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
671
727
  and child[0].text is not None
672
728
  and child[0].text.startswith("[!")
673
729
  ):
674
- return self._transform_alert(child)
730
+ return self._transform_github_alert(child)
731
+
732
+ # Alerts in GitLab
733
+ # <blockquote>
734
+ # <p>DISCLAIMER: ...</p>
735
+ # </blockquote>
736
+ elif (
737
+ child.tag == "blockquote"
738
+ and len(child) > 0
739
+ and child[0].tag == "p"
740
+ and child[0].text is not None
741
+ and starts_with_any(
742
+ child[0].text, ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"]
743
+ )
744
+ ):
745
+ return self._transform_gitlab_alert(child)
675
746
 
676
747
  # <details markdown="1">
677
748
  # <summary>...</summary>
@@ -726,8 +797,14 @@ class ConfluenceQualifiedID:
726
797
  page_id: str
727
798
  space_key: Optional[str] = None
728
799
 
800
+ def __init__(self, page_id: str, space_key: Optional[str] = None):
801
+ self.page_id = page_id
802
+ self.space_key = space_key
803
+
729
804
 
730
805
  def extract_qualified_id(string: str) -> Tuple[Optional[ConfluenceQualifiedID], str]:
806
+ "Extracts the Confluence page ID and space key from a Markdown document."
807
+
731
808
  page_id, string = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", string)
732
809
 
733
810
  if page_id is None:
@@ -741,6 +818,16 @@ def extract_qualified_id(string: str) -> Tuple[Optional[ConfluenceQualifiedID],
741
818
  return ConfluenceQualifiedID(page_id, space_key), string
742
819
 
743
820
 
821
+ def read_qualified_id(absolute_path: Path) -> Optional[ConfluenceQualifiedID]:
822
+ "Reads the Confluence page ID and space key from a Markdown document."
823
+
824
+ with open(absolute_path, "r", encoding="utf-8") as f:
825
+ document = f.read()
826
+
827
+ qualified_id, _ = extract_qualified_id(document)
828
+ return qualified_id
829
+
830
+
744
831
  @dataclass
745
832
  class ConfluenceDocumentOptions:
746
833
  """
@@ -754,6 +841,7 @@ class ConfluenceDocumentOptions:
754
841
  :param show_generated: Whether to display a prompt "This page has been generated with a tool."
755
842
  :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
756
843
  :param diagram_output_format: Target image format for diagrams.
844
+ :param webui_links: When true, convert relative URLs to Confluence Web UI links.
757
845
  """
758
846
 
759
847
  ignore_invalid_url: bool = False
@@ -762,6 +850,7 @@ class ConfluenceDocumentOptions:
762
850
  root_page_id: Optional[str] = None
763
851
  render_mermaid: bool = False
764
852
  diagram_output_format: Literal["png", "svg"] = "png"
853
+ webui_links: bool = False
765
854
 
766
855
 
767
856
  class ConfluenceDocument:
@@ -774,9 +863,9 @@ class ConfluenceDocument:
774
863
 
775
864
  def __init__(
776
865
  self,
777
- path: pathlib.Path,
866
+ path: Path,
778
867
  options: ConfluenceDocumentOptions,
779
- page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
868
+ page_metadata: Dict[Path, ConfluencePageMetadata],
780
869
  ) -> None:
781
870
  self.options = options
782
871
  path = path.absolute()
@@ -786,6 +875,13 @@ class ConfluenceDocument:
786
875
 
787
876
  # extract Confluence page ID
788
877
  qualified_id, text = extract_qualified_id(text)
878
+ if qualified_id is None:
879
+ # look up Confluence page ID in metadata
880
+ metadata = page_metadata.get(path)
881
+ if metadata is not None:
882
+ qualified_id = ConfluenceQualifiedID(
883
+ metadata.page_id, metadata.space_key
884
+ )
789
885
  if qualified_id is None:
790
886
  raise ValueError("missing Confluence page ID")
791
887
  self.id = qualified_id
@@ -823,6 +919,7 @@ class ConfluenceDocument:
823
919
  heading_anchors=self.options.heading_anchors,
824
920
  render_mermaid=self.options.render_mermaid,
825
921
  diagram_output_format=self.options.diagram_output_format,
922
+ webui_links=self.options.webui_links,
826
923
  ),
827
924
  path,
828
925
  page_metadata,
md2conf/processor.py CHANGED
@@ -1,12 +1,14 @@
1
+ import hashlib
1
2
  import logging
2
3
  import os
3
4
  from pathlib import Path
4
- from typing import Dict
5
+ from typing import Dict, List
5
6
 
6
7
  from .converter import (
7
8
  ConfluenceDocument,
8
9
  ConfluenceDocumentOptions,
9
10
  ConfluencePageMetadata,
11
+ ConfluenceQualifiedID,
10
12
  extract_qualified_id,
11
13
  )
12
14
  from .properties import ConfluenceProperties
@@ -37,28 +39,14 @@ class Processor:
37
39
  def process_directory(self, local_dir: Path) -> None:
38
40
  "Recursively scans a directory hierarchy for Markdown files."
39
41
 
40
- page_metadata: Dict[Path, ConfluencePageMetadata] = {}
41
42
  LOGGER.info(f"Synchronizing directory: {local_dir}")
42
43
 
43
44
  # Step 1: build index of all page metadata
44
- # NOTE: Pathlib.walk() is implemented only in Python 3.12+
45
- # so sticking for old os.walk
46
- for root, directories, files in os.walk(local_dir):
47
- for file_name in files:
48
- # Reconstitute Path object back
49
- docfile = (Path(root) / file_name).absolute()
50
-
51
- # Skip non-markdown files
52
- if docfile.suffix.lower() != ".md":
53
- continue
54
-
55
- metadata = self._get_page(docfile)
56
- LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
57
- page_metadata[docfile] = metadata
58
-
59
- LOGGER.info(f"indexed {len(page_metadata)} pages")
45
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
46
+ self._index_directory(local_dir, page_metadata)
47
+ LOGGER.info(f"indexed {len(page_metadata)} page(s)")
60
48
 
61
- # Step 2: Convert each page
49
+ # Step 2: convert each page
62
50
  for page_path in page_metadata.keys():
63
51
  self.process_page(page_path, page_metadata)
64
52
 
@@ -72,6 +60,34 @@ class Processor:
72
60
  with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
73
61
  f.write(content)
74
62
 
63
+ def _index_directory(
64
+ self,
65
+ local_dir: Path,
66
+ page_metadata: Dict[Path, ConfluencePageMetadata],
67
+ ) -> None:
68
+ "Indexes Markdown files in a directory recursively."
69
+
70
+ LOGGER.info(f"Indexing directory: {local_dir}")
71
+
72
+ files: List[Path] = []
73
+ directories: List[Path] = []
74
+ for entry in os.scandir(local_dir):
75
+ if entry.is_file():
76
+ if entry.name.endswith(".md"):
77
+ # skip non-markdown files
78
+ files.append((Path(local_dir) / entry.name).absolute())
79
+ elif entry.is_dir():
80
+ if not entry.name.startswith("."):
81
+ directories.append((Path(local_dir) / entry.name).absolute())
82
+
83
+ for doc in files:
84
+ metadata = self._get_page(doc)
85
+ LOGGER.debug(f"indexed {doc} with metadata: {metadata}")
86
+ page_metadata[doc] = metadata
87
+
88
+ for directory in directories:
89
+ self._index_directory(Path(local_dir) / directory, page_metadata)
90
+
75
91
  def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
76
92
  "Extracts metadata from a Markdown file."
77
93
 
@@ -80,7 +96,13 @@ class Processor:
80
96
 
81
97
  qualified_id, document = extract_qualified_id(document)
82
98
  if qualified_id is None:
83
- raise ValueError("required: page ID for local output")
99
+ if self.options.root_page_id is not None:
100
+ hash = hashlib.md5(document.encode("utf-8"))
101
+ digest = "".join(f"{c:x}" for c in hash.digest())
102
+ LOGGER.info(f"Identifier '{digest}' assigned to page: {absolute_path}")
103
+ qualified_id = ConfluenceQualifiedID(digest)
104
+ else:
105
+ raise ValueError("required: page ID for local output")
84
106
 
85
107
  return ConfluencePageMetadata(
86
108
  domain=self.properties.domain,
md2conf/properties.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import os
2
- from typing import Optional
2
+ from typing import Dict, Optional
3
3
 
4
4
 
5
5
  class ConfluenceError(RuntimeError):
@@ -12,6 +12,7 @@ class ConfluenceProperties:
12
12
  space_key: str
13
13
  user_name: Optional[str]
14
14
  api_key: str
15
+ headers: Optional[Dict[str, str]]
15
16
 
16
17
  def __init__(
17
18
  self,
@@ -20,6 +21,7 @@ class ConfluenceProperties:
20
21
  user_name: Optional[str] = None,
21
22
  api_key: Optional[str] = None,
22
23
  space_key: Optional[str] = None,
24
+ headers: Optional[Dict[str, str]] = None,
23
25
  ) -> None:
24
26
  opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
25
27
  opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
@@ -48,5 +50,4 @@ class ConfluenceProperties:
48
50
  self.user_name = opt_user_name
49
51
  self.api_key = opt_api_key
50
52
  self.space_key = opt_space_key
51
- self.space_key = opt_space_key
52
- self.space_key = opt_space_key
53
+ self.headers = headers
@@ -1,17 +0,0 @@
1
- md2conf/__init__.py,sha256=1KRpqiilQTkQz-oL8-HFPnI_6_3-_H0dq-SxQxDw56s,402
2
- md2conf/__main__.py,sha256=tWMEA_spxUTNNgViHtjsA85NzJixX-0G2zCq8BO3y_E,5230
3
- md2conf/api.py,sha256=Oc4FAQBNs85U8s-lbY0XwLBUcjm3Sd0_W59N4H3XAnE,15768
4
- md2conf/application.py,sha256=NnF84-cdW2cZUbU6VeHvuEg6g5NL5M9o2cpOSU7uv7o,5548
5
- md2conf/converter.py,sha256=XY7D8zpsVS7_PZzywciQ5YT2SHH5t1udPU5s2aPsmqs,27040
6
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
- md2conf/mermaid.py,sha256=3zawPXHXkCDhEK-WNtCH-gTqsLBDRzLrmlSo8ZW-Ii8,1371
8
- md2conf/processor.py,sha256=3JZkbFtMjbtnQLEm6wFum96ldjZ9xNJuL8JjFadyGmg,3084
9
- md2conf/properties.py,sha256=oXvtPssbougM1BTE9ytcD_1Yjc3nd7DDSHqEr0QoZAU,1811
10
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- markdown_to_confluence-0.2.0.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
12
- markdown_to_confluence-0.2.0.dist-info/METADATA,sha256=nxwG4F2TX1do0lk38BCFIUMwqv6y2edErd2_5M-4la4,10023
13
- markdown_to_confluence-0.2.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
14
- markdown_to_confluence-0.2.0.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
15
- markdown_to_confluence-0.2.0.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
16
- markdown_to_confluence-0.2.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
17
- markdown_to_confluence-0.2.0.dist-info/RECORD,,