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.
- {markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/METADATA +35 -12
- markdown_to_confluence-0.2.2.dist-info/RECORD +19 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +44 -2
- md2conf/api.py +32 -0
- md2conf/application.py +108 -39
- md2conf/converter.py +131 -34
- md2conf/matcher.py +83 -0
- md2conf/mermaid.py +41 -19
- md2conf/processor.py +45 -20
- md2conf/properties.py +4 -3
- md2conf/puppeteer-config.json +8 -0
- markdown_to_confluence-0.2.0.dist-info/RECORD +0 -17
- {markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/LICENSE +0 -0
- {markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/zip-safe +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.2.
|
|
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 [
|
|
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/
|
|
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]
|
|
160
|
-
[
|
|
161
|
-
[--render-mermaid-format {png,svg}] [--heading-anchors]
|
|
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
|
|
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
|
-
|
|
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 --
|
|
217
|
+
docker run --rm --env-file .env --name md2conf -v $(pwd):/data leventehunyadi/md2conf ./
|
|
199
218
|
```
|
|
200
219
|
|
|
201
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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,
|
|
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
|
|
145
|
+
if parent_id is None:
|
|
107
146
|
raise ValueError(
|
|
108
|
-
"expected:
|
|
147
|
+
f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
|
|
109
148
|
)
|
|
110
149
|
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
256
|
-
base_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[
|
|
271
|
+
page_metadata: Dict[Path, ConfluencePageMetadata]
|
|
261
272
|
|
|
262
273
|
def __init__(
|
|
263
274
|
self,
|
|
264
275
|
options: ConfluenceConverterOptions,
|
|
265
|
-
path:
|
|
266
|
-
page_metadata: Dict[
|
|
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=
|
|
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 =
|
|
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
|
|
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/
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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:
|
|
866
|
+
path: Path,
|
|
778
867
|
options: ConfluenceDocumentOptions,
|
|
779
|
-
page_metadata: Dict[
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
{markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.2.0.dist-info → markdown_to_confluence-0.2.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|