markdown-to-confluence 0.2.0__py3-none-any.whl → 0.2.2__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.2
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
 
@@ -144,21 +144,28 @@ Provide generated-by prompt text in the Markdown file with a tag:
144
144
 
145
145
  Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes precedence.
146
146
 
147
+ ### Ignoring files
148
+
149
+ Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
150
+
151
+ Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
152
+
147
153
  ### Running the tool
148
154
 
149
155
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
150
156
 
151
157
  ```sh
152
- $ python3 -m md2conf sample/example.md
158
+ $ python3 -m md2conf sample/index.md
153
159
  ```
154
160
 
155
161
  Use the `--help` switch to get a full list of supported command-line options:
156
162
 
157
163
  ```console
158
164
  $ 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]
165
+ usage: md2conf [-h] [--version] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
166
+ [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY] [--no-generated-by]
167
+ [--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors]
168
+ [--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
162
169
  mdpath
163
170
 
164
171
  positional arguments:
@@ -166,6 +173,7 @@ positional arguments:
166
173
 
167
174
  options:
168
175
  -h, --help show this help message and exit
176
+ --version show program's version number and exit
169
177
  -d DOMAIN, --domain DOMAIN
170
178
  Confluence organization domain.
171
179
  -p PATH, --path PATH Base path for Confluence (default: '/wiki/').
@@ -188,20 +196,35 @@ options:
188
196
  --heading-anchors Place an anchor at each section heading with GitHub-style same-page identifiers.
189
197
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
190
198
  --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
199
+ --headers [KEY=VALUE ...]
200
+ Apply custom headers to all Confluence API requests.
201
+ --webui-links Enable Confluence Web UI links.
191
202
  ```
192
203
 
193
- ### Using the docker container
204
+ ### Using the Docker container
205
+
206
+ You can run the Docker container via `docker run` or via `Dockerfile`. Either can accept the environment variables or arguments similar to the Python options. The final argument `./` corresponds to `mdpath` in the command-line utility.
207
+
208
+ With `docker run`, you can pass Confluence domain, user, API and space key directly to `docker run`:
194
209
 
195
- You can run the docker container via `docker run` or via `Dockerfile`. Either can accept the environment variables or arguments similar to the Python options. The final argument `./` corresponds to `mdpath` in the command-line utility.
210
+ ```sh
211
+ docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf -d instructure.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s DAP ./
212
+ ```
213
+
214
+ Alternatively, you can use a separate file `.env` to pass these parameters as environment variables:
196
215
 
197
216
  ```sh
198
- docker run --rm --name md2conf hunyadi/md2conf -d instructure.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s DAP ./
217
+ docker run --rm --env-file .env --name md2conf -v $(pwd):/data leventehunyadi/md2conf ./
199
218
  ```
200
219
 
201
- Note that the entry point for the docker container's base image is `ENTRYPOINT ["python3", "-m", "md2conf"]`.
220
+ In each case, `-v $(pwd):/data` maps the current directory to Docker container's `WORKDIR` such *md2conf* can scan files and directories in the local file system.
221
+
222
+ Note that the entry point for the Docker container's base image is `ENTRYPOINT ["python3", "-m", "md2conf"]`.
223
+
224
+ With the `Dockerfile` approach, you can extend the base image:
202
225
 
203
226
  ```Dockerfile
204
- FROM hunyadi/md2conf:latest
227
+ FROM leventehunyadi/md2conf:latest
205
228
 
206
229
  ENV CONFLUENCE_DOMAIN='instructure.atlassian.net'
207
230
  ENV CONFLUENCE_PATH='/wiki/'
@@ -215,7 +238,7 @@ CMD ["./"]
215
238
  Alternatively,
216
239
 
217
240
  ```Dockerfile
218
- FROM hunyadi/md2conf:latest
241
+ FROM leventehunyadi/md2conf:latest
219
242
 
220
243
  CMD ["-d", "instructure.atlassian.net", "-u", "levente.hunyadi@instructure.com", "-a", "0123456789abcdef", "-s", "DAP", "./"]
221
244
  ```
@@ -0,0 +1,19 @@
1
+ md2conf/__init__.py,sha256=1DSbQlz0zNxil7Lbsh7VjmGvJdtKhOjtd67r2elUSjE,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=mQusGnzu-ssFn9-aC_rGsqsWpDtw8qFJDnPW7cRkXC0,7762
5
+ md2conf/converter.py,sha256=_zFk-H4NZuY2Y58enVGgFNubOJv9EI2u8tS7RQRiD3A,30391
6
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
+ md2conf/matcher.py,sha256=SAmXQzQNan05jVcmZ8PEONynj-SEcVrkCHyXvBxEi2Q,2690
8
+ md2conf/mermaid.py,sha256=a7PVcd7kcFBOMw7Z2mOfvWC1JIVR4Q1EkkanLk1SLx0,1981
9
+ md2conf/processor.py,sha256=V_kxpk4da8vzSLx4Zixhf1sEWdVIxKZeJocJvWhOK6Y,4020
10
+ md2conf/properties.py,sha256=2l1tW8HmnrEsXN4-Dtby2tYJQTG1MirRpM3H6ykjQ4c,1858
11
+ md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
12
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ markdown_to_confluence-0.2.2.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
14
+ markdown_to_confluence-0.2.2.dist-info/METADATA,sha256=a_CQkC2-De5lcIAudWShsx0m1DIAtA6utrsJKcAi20I,11571
15
+ markdown_to_confluence-0.2.2.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
16
+ markdown_to_confluence-0.2.2.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
17
+ markdown_to_confluence-0.2.2.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
18
+ markdown_to_confluence-0.2.2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
19
+ markdown_to_confluence-0.2.2.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.2"
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,16 +1,19 @@
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
  )
16
+ from .matcher import Matcher, MatcherOptions
14
17
 
15
18
  LOGGER = logging.getLogger(__name__)
16
19
 
@@ -45,28 +48,19 @@ class Application:
45
48
  def synchronize_directory(self, local_dir: Path) -> None:
46
49
  "Synchronizes a directory of Markdown pages with Confluence."
47
50
 
48
- page_metadata: Dict[Path, ConfluencePageMetadata] = {}
49
51
  LOGGER.info(f"Synchronizing directory: {local_dir}")
50
52
 
51
53
  # 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")
54
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
55
+ root_id = (
56
+ ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
57
+ if self.options.root_page_id
58
+ else None
59
+ )
60
+ self._index_directory(local_dir, root_id, page_metadata)
61
+ LOGGER.info(f"indexed {len(page_metadata)} page(s)")
68
62
 
69
- # Step 2: Convert each page
63
+ # Step 2: convert each page
70
64
  for page_path in page_metadata.keys():
71
65
  self._synchronize_page(page_path, page_metadata)
72
66
 
@@ -86,8 +80,53 @@ class Application:
86
80
  else:
87
81
  self._update_document(document, base_path)
88
82
 
83
+ def _index_directory(
84
+ self,
85
+ local_dir: Path,
86
+ root_id: Optional[ConfluenceQualifiedID],
87
+ page_metadata: Dict[Path, ConfluencePageMetadata],
88
+ ) -> None:
89
+ "Indexes Markdown files in a directory recursively."
90
+
91
+ LOGGER.info(f"Indexing directory: {local_dir}")
92
+
93
+ matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
94
+
95
+ files: List[Path] = []
96
+ directories: List[Path] = []
97
+ for entry in os.scandir(local_dir):
98
+ if matcher.is_excluded(entry.name):
99
+ continue
100
+
101
+ if entry.is_file():
102
+ files.append((Path(local_dir) / entry.name).absolute())
103
+ elif entry.is_dir():
104
+ directories.append((Path(local_dir) / entry.name).absolute())
105
+
106
+ # make page act as parent node in Confluence
107
+ parent_id: Optional[ConfluenceQualifiedID] = None
108
+ if "index.md" in files:
109
+ parent_id = read_qualified_id(Path(local_dir) / "index.md")
110
+ elif "README.md" in files:
111
+ parent_id = read_qualified_id(Path(local_dir) / "README.md")
112
+
113
+ if parent_id is None:
114
+ parent_id = root_id
115
+
116
+ for doc in files:
117
+ metadata = self._get_or_create_page(doc, parent_id)
118
+ LOGGER.debug(f"indexed {doc} with metadata: {metadata}")
119
+ page_metadata[doc] = metadata
120
+
121
+ for directory in directories:
122
+ self._index_directory(Path(local_dir) / directory, parent_id, page_metadata)
123
+
89
124
  def _get_or_create_page(
90
- self, absolute_path: Path, title: Optional[str] = None
125
+ self,
126
+ absolute_path: Path,
127
+ parent_id: Optional[ConfluenceQualifiedID],
128
+ *,
129
+ title: Optional[str] = None,
91
130
  ) -> ConfluencePageMetadata:
92
131
  """
93
132
  Creates a new Confluence page if no page is linked in the Markdown document.
@@ -103,23 +142,13 @@ class Application:
103
142
  qualified_id.page_id, space_key=qualified_id.space_key
104
143
  )
105
144
  else:
106
- if self.options.root_page_id is None:
145
+ if parent_id is None:
107
146
  raise ValueError(
108
- "expected: Confluence page ID to act as parent for Markdown files with no linked Confluence page"
147
+ f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
109
148
  )
110
149
 
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,
150
+ confluence_page = self._create_page(
151
+ absolute_path, document, title, parent_id
123
152
  )
124
153
 
125
154
  return ConfluencePageMetadata(
@@ -130,7 +159,32 @@ class Application:
130
159
  title=confluence_page.title or "",
131
160
  )
132
161
 
162
+ def _create_page(
163
+ self,
164
+ absolute_path: Path,
165
+ document: str,
166
+ title: Optional[str],
167
+ parent_id: ConfluenceQualifiedID,
168
+ ) -> ConfluencePage:
169
+ "Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet."
170
+
171
+ # use file name without extension if no title is supplied
172
+ if title is None:
173
+ title = absolute_path.stem
174
+
175
+ confluence_page = self.api.get_or_create_page(
176
+ title, parent_id.page_id, space_key=parent_id.space_key
177
+ )
178
+ self._update_markdown(
179
+ absolute_path,
180
+ document,
181
+ confluence_page.id,
182
+ confluence_page.space_key,
183
+ )
184
+ return confluence_page
185
+
133
186
  def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
187
+ "Saves a new version of a Confluence document."
134
188
 
135
189
  for image in document.images:
136
190
  self.api.upload_attachment(
@@ -158,8 +212,23 @@ class Application:
158
212
  page_id: str,
159
213
  space_key: Optional[str],
160
214
  ) -> None:
215
+ "Writes the Confluence page ID and space key at the beginning of the Markdown file."
216
+
217
+ content: List[str] = []
218
+
219
+ # check if the file has frontmatter
220
+ index = 0
221
+ if document.startswith("---\n"):
222
+ index = document.find("\n---\n", 4) + 4
223
+
224
+ # insert the Confluence keys after the frontmatter
225
+ content.append(document[:index])
226
+
227
+ content.append(f"<!-- confluence-page-id: {page_id} -->")
228
+ if space_key:
229
+ content.append(f"<!-- confluence-space-key: {space_key} -->")
230
+
231
+ content.append(document[index:])
232
+
161
233
  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)
234
+ 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/matcher.py ADDED
@@ -0,0 +1,83 @@
1
+ import os.path
2
+ from dataclasses import dataclass
3
+ from fnmatch import fnmatch
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Optional
6
+
7
+
8
+ @dataclass
9
+ class MatcherOptions:
10
+ """
11
+ Options for checking against a list of exclude/include patterns.
12
+
13
+ :param source: File name to read exclusion rules from.
14
+ :param extension: Extension to narrow down search to.
15
+ """
16
+
17
+ source: str
18
+ extension: Optional[str] = None
19
+
20
+ def __post_init__(self) -> None:
21
+ if self.extension is not None and not self.extension.startswith("."):
22
+ self.extension = f".{self.extension}"
23
+
24
+
25
+ class Matcher:
26
+ "Compares file and directory names against a list of exclude/include patterns."
27
+
28
+ options: MatcherOptions
29
+ rules: List[str]
30
+
31
+ def __init__(self, options: MatcherOptions, directory: Path) -> None:
32
+ self.options = options
33
+ if os.path.exists(directory / options.source):
34
+ with open(directory / options.source, "r") as f:
35
+ rules = f.read().splitlines()
36
+ self.rules = [rule for rule in rules if rule and not rule.startswith("#")]
37
+ else:
38
+ self.rules = []
39
+
40
+ def extension_matches(self, name: str) -> bool:
41
+ "True if the file name has the expected extension."
42
+
43
+ return self.options.extension is None or name.endswith(self.options.extension)
44
+
45
+ def is_excluded(self, name: str) -> bool:
46
+ "True if the file or directory name matches any of the exclusion patterns."
47
+
48
+ if name.startswith("."):
49
+ return True
50
+
51
+ if not self.extension_matches(name):
52
+ return True
53
+
54
+ for rule in self.rules:
55
+ if fnmatch(name, rule):
56
+ return True
57
+ else:
58
+ return False
59
+
60
+ def is_included(self, name: str) -> bool:
61
+ "True if the file or directory name matches none of the exclusion patterns."
62
+
63
+ return not self.is_excluded(name)
64
+
65
+ def filter(self, items: Iterable[str]) -> List[str]:
66
+ """
67
+ Returns only those elements from the input that don't match any of the exclusion rules.
68
+
69
+ :param items: A list of names to filter.
70
+ :returns: A filtered list of names that didn't match any of the exclusion rules.
71
+ """
72
+
73
+ return [item for item in items if self.is_included(item)]
74
+
75
+ def scandir(self, path: Path) -> List[str]:
76
+ """
77
+ Returns only those entries in a directory whose name doesn't match any of the exclusion rules.
78
+
79
+ :param path: Directory to scan.
80
+ :returns: A filtered list of entries whose name didn't match any of the exclusion rules.
81
+ """
82
+
83
+ return self.filter(entry.name for entry in os.scandir(path))
md2conf/mermaid.py CHANGED
@@ -1,17 +1,37 @@
1
+ import logging
1
2
  import os
2
3
  import os.path
3
4
  import shutil
4
5
  import subprocess
5
6
  from typing import Literal
6
7
 
8
+ LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ def is_docker() -> bool:
12
+ "True if the application is running in a Docker container."
13
+
14
+ return (
15
+ os.environ.get("CHROME_BIN") == "/usr/bin/chromium-browser"
16
+ and os.environ.get("PUPPETEER_SKIP_DOWNLOAD") == "true"
17
+ )
18
+
19
+
20
+ def get_mmdc() -> str:
21
+ "Path to the Mermaid diagram converter."
22
+
23
+ if is_docker():
24
+ return "/home/md2conf/node_modules/.bin/mmdc"
25
+ elif os.name == "nt":
26
+ return "mmdc.cmd"
27
+ else:
28
+ return "mmdc"
29
+
7
30
 
8
31
  def has_mmdc() -> bool:
9
32
  "True if Mermaid diagram converter is available on the OS."
10
33
 
11
- if os.name == "nt":
12
- executable = "mmdc.cmd"
13
- else:
14
- executable = "mmdc"
34
+ executable = get_mmdc()
15
35
  return shutil.which(executable) is not None
16
36
 
17
37
 
@@ -20,20 +40,21 @@ def render(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
20
40
 
21
41
  filename = f"tmp_mermaid.{output_format}"
22
42
 
23
- if os.name == "nt":
24
- executable = "mmdc.cmd"
25
- else:
26
- executable = "mmdc"
43
+ cmd = [
44
+ get_mmdc(),
45
+ "--input",
46
+ "-",
47
+ "--output",
48
+ filename,
49
+ "--outputFormat",
50
+ output_format,
51
+ ]
52
+ if is_docker():
53
+ cmd.extend(
54
+ ["-p", os.path.join(os.path.dirname(__file__), "puppeteer-config.json")]
55
+ )
56
+ LOGGER.debug(f"Executing: {' '.join(cmd)}")
27
57
  try:
28
- cmd = [
29
- executable,
30
- "--input",
31
- "-",
32
- "--output",
33
- filename,
34
- "--outputFormat",
35
- output_format,
36
- ]
37
58
  proc = subprocess.Popen(
38
59
  cmd,
39
60
  stdout=subprocess.PIPE,
@@ -41,10 +62,11 @@ def render(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
41
62
  stderr=subprocess.PIPE,
42
63
  text=False,
43
64
  )
44
- proc.communicate(input=source.encode("utf-8"))
65
+ stdout, stderr = proc.communicate(input=source.encode("utf-8"))
45
66
  if proc.returncode:
46
67
  raise RuntimeError(
47
- f"failed to convert Mermaid diagram; exit code: {proc.returncode}"
68
+ f"failed to convert Mermaid diagram; exit code: {proc.returncode}, "
69
+ f"output:\n{stdout.decode('utf-8')}\n{stderr.decode('utf-8')}"
48
70
  )
49
71
  with open(filename, "rb") as image:
50
72
  return image.read()
md2conf/processor.py CHANGED
@@ -1,14 +1,17 @@
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
  )
14
+ from .matcher import Matcher, MatcherOptions
12
15
  from .properties import ConfluenceProperties
13
16
 
14
17
  LOGGER = logging.getLogger(__name__)
@@ -37,28 +40,14 @@ class Processor:
37
40
  def process_directory(self, local_dir: Path) -> None:
38
41
  "Recursively scans a directory hierarchy for Markdown files."
39
42
 
40
- page_metadata: Dict[Path, ConfluencePageMetadata] = {}
41
43
  LOGGER.info(f"Synchronizing directory: {local_dir}")
42
44
 
43
45
  # 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")
46
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
47
+ self._index_directory(local_dir, page_metadata)
48
+ LOGGER.info(f"indexed {len(page_metadata)} page(s)")
60
49
 
61
- # Step 2: Convert each page
50
+ # Step 2: convert each page
62
51
  for page_path in page_metadata.keys():
63
52
  self.process_page(page_path, page_metadata)
64
53
 
@@ -72,6 +61,36 @@ class Processor:
72
61
  with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
73
62
  f.write(content)
74
63
 
64
+ def _index_directory(
65
+ self,
66
+ local_dir: Path,
67
+ page_metadata: Dict[Path, ConfluencePageMetadata],
68
+ ) -> None:
69
+ "Indexes Markdown files in a directory recursively."
70
+
71
+ LOGGER.info(f"Indexing directory: {local_dir}")
72
+
73
+ matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
74
+
75
+ files: List[Path] = []
76
+ directories: List[Path] = []
77
+ for entry in os.scandir(local_dir):
78
+ if matcher.is_excluded(entry.name):
79
+ continue
80
+
81
+ if entry.is_file():
82
+ files.append((Path(local_dir) / entry.name).absolute())
83
+ elif entry.is_dir():
84
+ directories.append((Path(local_dir) / entry.name).absolute())
85
+
86
+ for doc in files:
87
+ metadata = self._get_page(doc)
88
+ LOGGER.debug(f"indexed {doc} with metadata: {metadata}")
89
+ page_metadata[doc] = metadata
90
+
91
+ for directory in directories:
92
+ self._index_directory(Path(local_dir) / directory, page_metadata)
93
+
75
94
  def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
76
95
  "Extracts metadata from a Markdown file."
77
96
 
@@ -80,7 +99,13 @@ class Processor:
80
99
 
81
100
  qualified_id, document = extract_qualified_id(document)
82
101
  if qualified_id is None:
83
- raise ValueError("required: page ID for local output")
102
+ if self.options.root_page_id is not None:
103
+ hash = hashlib.md5(document.encode("utf-8"))
104
+ digest = "".join(f"{c:x}" for c in hash.digest())
105
+ LOGGER.info(f"Identifier '{digest}' assigned to page: {absolute_path}")
106
+ qualified_id = ConfluenceQualifiedID(digest)
107
+ else:
108
+ raise ValueError("required: page ID for local output")
84
109
 
85
110
  return ConfluencePageMetadata(
86
111
  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
@@ -0,0 +1,8 @@
1
+ {
2
+ "executablePath": "/usr/bin/chromium-browser",
3
+ "args": [
4
+ "--no-sandbox",
5
+ "--disable-gpu",
6
+ "--disable-setuid-sandbox"
7
+ ]
8
+ }
@@ -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,,