markdown-to-confluence 0.2.0__tar.gz → 0.2.1__tar.gz
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 → markdown_to_confluence-0.2.1}/PKG-INFO +11 -6
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/README.md +10 -5
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/PKG-INFO +11 -6
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/__init__.py +1 -1
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/__main__.py +44 -2
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/api.py +32 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/application.py +104 -38
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/converter.py +131 -34
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/processor.py +42 -20
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/properties.py +4 -3
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/setup.cfg +1 -1
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/tests/test_processor.py +2 -2
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/LICENSE +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/SOURCES.txt +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/entry_points.txt +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/requires.txt +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/top_level.txt +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/markdown_to_confluence.egg-info/zip-safe +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/entities.dtd +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/mermaid.py +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/md2conf/py.typed +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/pyproject.toml +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/setup.py +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/tests/test_conversion.py +0 -0
- {markdown_to_confluence-0.2.0 → markdown_to_confluence-0.2.1}/tests/test_mermaid.py +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.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 [
|
|
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/
|
|
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]
|
|
160
|
-
[
|
|
161
|
-
[--render-mermaid-format {png,svg}] [--heading-anchors]
|
|
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
|
|
@@ -20,7 +20,7 @@ This Python package
|
|
|
20
20
|
* Image references (uploaded as Confluence page attachments)
|
|
21
21
|
* Tables
|
|
22
22
|
* [Table of contents](https://docs.gitlab.com/ee/user/markdown.html#table-of-contents)
|
|
23
|
-
* [Admonitions](https://python-markdown.github.io/extensions/admonition/) and [
|
|
23
|
+
* [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)
|
|
24
24
|
* [Collapsed sections](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections)
|
|
25
25
|
* [Mermaid diagrams](https://mermaid.live/) in code blocks (converted to images)
|
|
26
26
|
|
|
@@ -118,16 +118,17 @@ Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes prece
|
|
|
118
118
|
You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
|
|
119
119
|
|
|
120
120
|
```sh
|
|
121
|
-
$ python3 -m md2conf sample/
|
|
121
|
+
$ python3 -m md2conf sample/index.md
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
Use the `--help` switch to get a full list of supported command-line options:
|
|
125
125
|
|
|
126
126
|
```console
|
|
127
127
|
$ python3 -m md2conf --help
|
|
128
|
-
usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
|
|
129
|
-
[
|
|
130
|
-
[--render-mermaid-format {png,svg}] [--heading-anchors]
|
|
128
|
+
usage: md2conf [-h] [--version] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
|
|
129
|
+
[-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY] [--no-generated-by]
|
|
130
|
+
[--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors]
|
|
131
|
+
[--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
|
|
131
132
|
mdpath
|
|
132
133
|
|
|
133
134
|
positional arguments:
|
|
@@ -135,6 +136,7 @@ positional arguments:
|
|
|
135
136
|
|
|
136
137
|
options:
|
|
137
138
|
-h, --help show this help message and exit
|
|
139
|
+
--version show program's version number and exit
|
|
138
140
|
-d DOMAIN, --domain DOMAIN
|
|
139
141
|
Confluence organization domain.
|
|
140
142
|
-p PATH, --path PATH Base path for Confluence (default: '/wiki/').
|
|
@@ -157,6 +159,9 @@ options:
|
|
|
157
159
|
--heading-anchors Place an anchor at each section heading with GitHub-style same-page identifiers.
|
|
158
160
|
--ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
|
|
159
161
|
--local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
|
|
162
|
+
--headers [KEY=VALUE ...]
|
|
163
|
+
Apply custom headers to all Confluence API requests.
|
|
164
|
+
--webui-links Enable Confluence Web UI links.
|
|
160
165
|
```
|
|
161
166
|
|
|
162
167
|
### Using the docker container
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.2.
|
|
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 [
|
|
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/
|
|
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]
|
|
160
|
-
[
|
|
161
|
-
[--render-mermaid-format {png,svg}] [--heading-anchors]
|
|
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
|
|
@@ -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.1"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
10
|
__copyright__ = "Copyright 2022-2024, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
|
@@ -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)
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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")
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
163
|
-
if space_key:
|
|
164
|
-
file.write(f"<!-- confluence-space-key: {space_key} -->\n")
|
|
165
|
-
file.write(document)
|
|
231
|
+
file.write("\n".join(content))
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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")
|
|
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:
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
@@ -41,7 +41,7 @@ class TestProcessor(unittest.TestCase):
|
|
|
41
41
|
)
|
|
42
42
|
Processor(options, properties).process(self.sample_dir / "code.md")
|
|
43
43
|
|
|
44
|
-
self.assertTrue((self.sample_dir / "
|
|
44
|
+
self.assertTrue((self.sample_dir / "index.csf").exists())
|
|
45
45
|
|
|
46
46
|
def test_process_directory(self) -> None:
|
|
47
47
|
options = ConfluenceDocumentOptions(
|
|
@@ -55,7 +55,7 @@ class TestProcessor(unittest.TestCase):
|
|
|
55
55
|
)
|
|
56
56
|
Processor(options, properties).process(self.sample_dir)
|
|
57
57
|
|
|
58
|
-
self.assertTrue((self.sample_dir / "
|
|
58
|
+
self.assertTrue((self.sample_dir / "index.csf").exists())
|
|
59
59
|
self.assertTrue((self.sample_dir / "sibling.csf").exists())
|
|
60
60
|
self.assertTrue((self.sample_dir / "code.csf").exists())
|
|
61
61
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|