markdown-to-confluence 0.1.10__py3-none-any.whl → 0.1.12__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.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/LICENSE +1 -1
- {markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/METADATA +21 -16
- markdown_to_confluence-0.1.12.dist-info/RECORD +16 -0
- {markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/WHEEL +1 -1
- markdown_to_confluence-0.1.12.dist-info/entry_points.txt +2 -0
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +110 -68
- md2conf/api.py +166 -72
- md2conf/application.py +85 -35
- md2conf/converter.py +91 -30
- md2conf/processor.py +91 -0
- md2conf/properties.py +52 -0
- markdown_to_confluence-0.1.10.dist-info/RECORD +0 -13
- {markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/zip-safe +0 -0
{markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.12
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
@@ -21,13 +21,13 @@ Classifier: Typing :: Typed
|
|
|
21
21
|
Requires-Python: >=3.8
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
|
-
Requires-Dist: lxml >=
|
|
25
|
-
Requires-Dist: types-lxml >=
|
|
26
|
-
Requires-Dist: markdown >=3.
|
|
27
|
-
Requires-Dist: types-markdown >=3.
|
|
28
|
-
Requires-Dist: pymdown-extensions >=10.
|
|
29
|
-
Requires-Dist: requests >=2.
|
|
30
|
-
Requires-Dist: types-requests >=2.
|
|
24
|
+
Requires-Dist: lxml >=5.2
|
|
25
|
+
Requires-Dist: types-lxml >=2024.4.14
|
|
26
|
+
Requires-Dist: markdown >=3.6
|
|
27
|
+
Requires-Dist: types-markdown >=3.6
|
|
28
|
+
Requires-Dist: pymdown-extensions >=10.8
|
|
29
|
+
Requires-Dist: requests >=2.32
|
|
30
|
+
Requires-Dist: types-requests >=2.32
|
|
31
31
|
|
|
32
32
|
# Publish Markdown files to Confluence wiki
|
|
33
33
|
|
|
@@ -36,6 +36,7 @@ Contributors to software projects typically write documentation in Markdown form
|
|
|
36
36
|
Replicating documentation to Confluence by hand is tedious, and a lack of automated synchronization with the project repositories where the documents live leads to outdated documentation.
|
|
37
37
|
|
|
38
38
|
This Python package
|
|
39
|
+
|
|
39
40
|
* parses Markdown files,
|
|
40
41
|
* converts Markdown content into the Confluence Storage Format (XHTML),
|
|
41
42
|
* invokes Confluence API endpoints to upload images and content.
|
|
@@ -54,6 +55,7 @@ This Python package
|
|
|
54
55
|
## Getting started
|
|
55
56
|
|
|
56
57
|
In order to get started, you will need
|
|
58
|
+
|
|
57
59
|
* your organization domain name (e.g. `instructure.atlassian.net`),
|
|
58
60
|
* base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
|
|
59
61
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
|
|
@@ -62,7 +64,7 @@ In order to get started, you will need
|
|
|
62
64
|
|
|
63
65
|
### Obtaining an API token
|
|
64
66
|
|
|
65
|
-
1. Log in to https://id.atlassian.com/manage/api-tokens
|
|
67
|
+
1. Log in to <https://id.atlassian.com/manage/api-tokens>.
|
|
66
68
|
2. Click *Create API token*.
|
|
67
69
|
3. From the dialog that appears, enter a memorable and concise *Label* for your token and click *Create*.
|
|
68
70
|
4. Click *Copy to clipboard*, then paste the token to your script, or elsewhere to save.
|
|
@@ -70,6 +72,7 @@ In order to get started, you will need
|
|
|
70
72
|
### Setting up the environment
|
|
71
73
|
|
|
72
74
|
Confluence organization domain, base path, username, API token and space key can be specified at runtime or set as Confluence environment variables (e.g. add to your `~/.profile` on Linux, or `~/.bash_profile` or `~/.zshenv` on MacOS):
|
|
75
|
+
|
|
73
76
|
```bash
|
|
74
77
|
export CONFLUENCE_DOMAIN='instructure.atlassian.net'
|
|
75
78
|
export CONFLUENCE_PATH='/wiki/'
|
|
@@ -136,28 +139,30 @@ Use the `--help` switch to get a full list of supported command-line options:
|
|
|
136
139
|
|
|
137
140
|
```console
|
|
138
141
|
$ python3 -m md2conf --help
|
|
139
|
-
usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
|
|
140
|
-
[-
|
|
142
|
+
usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY]
|
|
143
|
+
[--no-generated-by] [--ignore-invalid-url] [--local]
|
|
141
144
|
mdpath
|
|
142
145
|
|
|
143
146
|
positional arguments:
|
|
144
147
|
mdpath Path to Markdown file or directory to convert and publish.
|
|
145
148
|
|
|
146
|
-
|
|
149
|
+
optional arguments:
|
|
147
150
|
-h, --help show this help message and exit
|
|
148
151
|
-d DOMAIN, --domain DOMAIN
|
|
149
152
|
Confluence organization domain.
|
|
150
|
-
-p PATH, --path PATH Base path for
|
|
153
|
+
-p PATH, --path PATH Base path for Confluence (default: '/wiki/').
|
|
151
154
|
-u USERNAME, --username USERNAME
|
|
152
155
|
Confluence user name.
|
|
153
156
|
-a APIKEY, --apikey APIKEY
|
|
154
157
|
Confluence API key. Refer to documentation how to obtain one.
|
|
155
158
|
-s SPACE, --space SPACE
|
|
156
|
-
Confluence space key for pages to be published. If omitted, will default to user
|
|
157
|
-
|
|
158
|
-
-l {DEBUG,INFO,WARNING,ERROR,CRITICAL}, --loglevel {DEBUG,INFO,WARNING,ERROR,CRITICAL}
|
|
159
|
+
Confluence space key for pages to be published. If omitted, will default to user space.
|
|
160
|
+
-l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
|
|
159
161
|
Use this option to set the log verbosity.
|
|
162
|
+
-r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
|
|
160
163
|
--generated-by GENERATED_BY
|
|
161
164
|
Add prompt to pages (default: 'This page has been generated with a tool.').
|
|
162
165
|
--no-generated-by Do not add 'generated by a tool' prompt to pages.
|
|
166
|
+
--ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
|
|
167
|
+
--local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
|
|
163
168
|
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
md2conf/__init__.py,sha256=SS79zE1Jss2UJQH39HX6zMvAPO2MGjq3jjHny4L6dvY,403
|
|
2
|
+
md2conf/__main__.py,sha256=L0XaHfV3GOPnyceWxUGamZXPfQUL3Dfsf_VpWeF08Qo,4246
|
|
3
|
+
md2conf/api.py,sha256=xYqQbdy-0d_mqv-zDT0DCcLs17HYLdY2LfWoY2jhMB8,14738
|
|
4
|
+
md2conf/application.py,sha256=ksWeDQGQs6xvAS8wdghwBMgWsGI6KMQiwlH5qaKXGv4,5221
|
|
5
|
+
md2conf/converter.py,sha256=xiaf5hQHXPE3psTTk_pHVip4NDfeU-hos3Kkl1Pju50,18236
|
|
6
|
+
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
7
|
+
md2conf/processor.py,sha256=f0JG8jqjsGq2sbxMdHNz6EyZ1KQ92nCKKYU3fxP6C0o,3048
|
|
8
|
+
md2conf/properties.py,sha256=oXvtPssbougM1BTE9ytcD_1Yjc3nd7DDSHqEr0QoZAU,1811
|
|
9
|
+
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
markdown_to_confluence-0.1.12.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
11
|
+
markdown_to_confluence-0.1.12.dist-info/METADATA,sha256=HCmOX5YhQuc6v2szBgQa_VuGXaeZNKKBy8Q_Fi1GreQ,7875
|
|
12
|
+
markdown_to_confluence-0.1.12.dist-info/WHEEL,sha256=cpQTJ5IWu9CdaPViMhC9YzF8gZuS5-vlfoFihTBC86A,91
|
|
13
|
+
markdown_to_confluence-0.1.12.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
14
|
+
markdown_to_confluence-0.1.12.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
15
|
+
markdown_to_confluence-0.1.12.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
16
|
+
markdown_to_confluence-0.1.12.dist-info/RECORD,,
|
md2conf/__init__.py
CHANGED
|
@@ -5,9 +5,9 @@ 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.1.
|
|
8
|
+
__version__ = "0.1.12"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
|
-
__copyright__ = "Copyright 2022-
|
|
10
|
+
__copyright__ = "Copyright 2022-2024, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
|
12
12
|
__maintainer__ = "Levente Hunyadi"
|
|
13
13
|
__status__ = "Production"
|
md2conf/__main__.py
CHANGED
|
@@ -3,6 +3,7 @@ import logging
|
|
|
3
3
|
import os.path
|
|
4
4
|
import sys
|
|
5
5
|
import typing
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import Optional
|
|
7
8
|
|
|
8
9
|
import requests
|
|
@@ -10,89 +11,130 @@ import requests
|
|
|
10
11
|
from .api import ConfluenceAPI
|
|
11
12
|
from .application import Application
|
|
12
13
|
from .converter import ConfluenceDocumentOptions
|
|
14
|
+
from .processor import Processor
|
|
15
|
+
from .properties import ConfluenceProperties
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
class Arguments(argparse.Namespace):
|
|
16
|
-
mdpath:
|
|
19
|
+
mdpath: Path
|
|
17
20
|
domain: str
|
|
18
21
|
path: str
|
|
19
22
|
username: str
|
|
20
23
|
apikey: str
|
|
21
24
|
space: str
|
|
22
25
|
loglevel: str
|
|
26
|
+
ignore_invalid_url: bool
|
|
23
27
|
generated_by: Optional[str]
|
|
24
28
|
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
parser
|
|
28
|
-
parser.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
parser.add_argument("-
|
|
33
|
-
parser.add_argument(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"--
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
logging.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
30
|
+
def main() -> None:
|
|
31
|
+
parser = argparse.ArgumentParser()
|
|
32
|
+
parser.prog = os.path.basename(os.path.dirname(__file__))
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"mdpath", help="Path to Markdown file or directory to convert and publish."
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("-d", "--domain", help="Confluence organization domain.")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-p", "--path", help="Base path for Confluence (default: '/wiki/')."
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("-u", "--username", help="Confluence user name.")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"-a",
|
|
43
|
+
"--apikey",
|
|
44
|
+
help="Confluence API key. Refer to documentation how to obtain one.",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"-s",
|
|
48
|
+
"--space",
|
|
49
|
+
help="Confluence space key for pages to be published. If omitted, will default to user space.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"-l",
|
|
53
|
+
"--loglevel",
|
|
54
|
+
choices=[
|
|
55
|
+
typing.cast(str, logging.getLevelName(level)).lower()
|
|
56
|
+
for level in (
|
|
57
|
+
logging.DEBUG,
|
|
58
|
+
logging.INFO,
|
|
59
|
+
logging.WARN,
|
|
60
|
+
logging.ERROR,
|
|
61
|
+
logging.CRITICAL,
|
|
62
|
+
)
|
|
63
|
+
],
|
|
64
|
+
default=logging.getLevelName(logging.INFO),
|
|
65
|
+
help="Use this option to set the log verbosity.",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"-r",
|
|
69
|
+
dest="root_page",
|
|
70
|
+
help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--generated-by",
|
|
74
|
+
default="This page has been generated with a tool.",
|
|
75
|
+
help="Add prompt to pages (default: 'This page has been generated with a tool.').",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--no-generated-by",
|
|
79
|
+
dest="generated_by",
|
|
80
|
+
action="store_const",
|
|
81
|
+
const=None,
|
|
82
|
+
help="Do not add 'generated by a tool' prompt to pages.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--ignore-invalid-url",
|
|
86
|
+
action="store_true",
|
|
87
|
+
default=False,
|
|
88
|
+
help="Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--local",
|
|
92
|
+
action="store_true",
|
|
93
|
+
default=False,
|
|
94
|
+
help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
|
|
95
|
+
)
|
|
72
96
|
|
|
73
|
-
args = Arguments()
|
|
74
|
-
parser.parse_args(namespace=args)
|
|
97
|
+
args = Arguments()
|
|
98
|
+
parser.parse_args(namespace=args)
|
|
75
99
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
100
|
+
# NOTE: If we switch to modern type aware CLI tool like typer
|
|
101
|
+
# the following line won't be necessary
|
|
102
|
+
args.mdpath = Path(args.mdpath)
|
|
80
103
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
Application(api, ConfluenceDocumentOptions(args.generated_by)).synchronize(
|
|
86
|
-
args.mdpath
|
|
87
|
-
)
|
|
88
|
-
except requests.exceptions.HTTPError as err:
|
|
89
|
-
logging.error(err)
|
|
104
|
+
logging.basicConfig(
|
|
105
|
+
level=getattr(logging, args.loglevel.upper(), logging.INFO),
|
|
106
|
+
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
107
|
+
)
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
|
|
109
|
+
options = ConfluenceDocumentOptions(
|
|
110
|
+
ignore_invalid_url=args.ignore_invalid_url,
|
|
111
|
+
generated_by=args.generated_by,
|
|
112
|
+
root_page_id=args.root_page,
|
|
113
|
+
)
|
|
114
|
+
properties = ConfluenceProperties(
|
|
115
|
+
args.domain, args.path, args.username, args.apikey, args.space
|
|
116
|
+
)
|
|
117
|
+
if args.local:
|
|
118
|
+
Processor(options, properties).process(args.mdpath)
|
|
119
|
+
else:
|
|
93
120
|
try:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
with ConfluenceAPI(properties) as api:
|
|
122
|
+
Application(
|
|
123
|
+
api,
|
|
124
|
+
options,
|
|
125
|
+
).synchronize(args.mdpath)
|
|
126
|
+
except requests.exceptions.HTTPError as err:
|
|
127
|
+
logging.error(err)
|
|
128
|
+
|
|
129
|
+
# print details for a response with JSON body
|
|
130
|
+
if err.response is not None:
|
|
131
|
+
try:
|
|
132
|
+
logging.error(err.response.json())
|
|
133
|
+
except requests.exceptions.JSONDecodeError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
97
138
|
|
|
98
|
-
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
md2conf/api.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import mimetypes
|
|
4
|
-
import os
|
|
5
|
-
import os.path
|
|
6
4
|
import sys
|
|
7
5
|
import typing
|
|
8
6
|
from contextlib import contextmanager
|
|
9
7
|
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
10
9
|
from types import TracebackType
|
|
11
10
|
from typing import Dict, Generator, List, Optional, Type, Union
|
|
12
11
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
@@ -14,6 +13,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
|
|
|
14
13
|
import requests
|
|
15
14
|
|
|
16
15
|
from .converter import ParseError, sanitize_confluence
|
|
16
|
+
from .properties import ConfluenceError, ConfluenceProperties
|
|
17
17
|
|
|
18
18
|
# a JSON type with possible `null` values
|
|
19
19
|
JsonType = Union[
|
|
@@ -56,7 +56,8 @@ else:
|
|
|
56
56
|
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
57
57
|
|
|
58
58
|
if string.startswith(prefix):
|
|
59
|
-
|
|
59
|
+
prefix_len = len(prefix)
|
|
60
|
+
return string[prefix_len:]
|
|
60
61
|
else:
|
|
61
62
|
return string
|
|
62
63
|
|
|
@@ -64,10 +65,6 @@ else:
|
|
|
64
65
|
LOGGER = logging.getLogger(__name__)
|
|
65
66
|
|
|
66
67
|
|
|
67
|
-
class ConfluenceError(RuntimeError):
|
|
68
|
-
pass
|
|
69
|
-
|
|
70
|
-
|
|
71
68
|
@dataclass
|
|
72
69
|
class ConfluenceAttachment:
|
|
73
70
|
id: str
|
|
@@ -79,64 +76,32 @@ class ConfluenceAttachment:
|
|
|
79
76
|
@dataclass
|
|
80
77
|
class ConfluencePage:
|
|
81
78
|
id: str
|
|
79
|
+
space_key: str
|
|
82
80
|
title: str
|
|
83
81
|
version: int
|
|
84
82
|
content: str
|
|
85
83
|
|
|
86
84
|
|
|
87
85
|
class ConfluenceAPI:
|
|
88
|
-
|
|
89
|
-
base_path: str
|
|
90
|
-
space_key: str
|
|
91
|
-
user_name: Optional[str]
|
|
92
|
-
api_key: str
|
|
93
|
-
|
|
86
|
+
properties: ConfluenceProperties
|
|
94
87
|
session: Optional["ConfluenceSession"] = None
|
|
95
88
|
|
|
96
|
-
def __init__(
|
|
97
|
-
self
|
|
98
|
-
domain: Optional[str] = None,
|
|
99
|
-
base_path: Optional[str] = None,
|
|
100
|
-
user_name: Optional[str] = None,
|
|
101
|
-
api_key: Optional[str] = None,
|
|
102
|
-
space_key: Optional[str] = None,
|
|
103
|
-
) -> None:
|
|
104
|
-
opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
|
|
105
|
-
opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
|
|
106
|
-
opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
|
|
107
|
-
opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
|
|
108
|
-
opt_space_key = space_key or os.getenv("CONFLUENCE_SPACE_KEY")
|
|
109
|
-
|
|
110
|
-
if not opt_domain:
|
|
111
|
-
raise ConfluenceError("Confluence domain not specified")
|
|
112
|
-
if not opt_base_path:
|
|
113
|
-
opt_base_path = "/wiki/"
|
|
114
|
-
if not opt_api_key:
|
|
115
|
-
raise ConfluenceError("Confluence API key not specified")
|
|
116
|
-
if not opt_space_key:
|
|
117
|
-
raise ConfluenceError("Confluence space key not specified")
|
|
118
|
-
|
|
119
|
-
if opt_domain.startswith(("http://", "https://")) or opt_domain.endswith("/"):
|
|
120
|
-
raise ConfluenceError(
|
|
121
|
-
"Confluence domain looks like a URL; only host name required"
|
|
122
|
-
)
|
|
123
|
-
if not opt_base_path.startswith("/") or not opt_base_path.endswith("/"):
|
|
124
|
-
raise ConfluenceError("Confluence base path must start and end with a '/'")
|
|
125
|
-
|
|
126
|
-
self.domain = opt_domain
|
|
127
|
-
self.base_path = opt_base_path
|
|
128
|
-
self.user_name = opt_user_name
|
|
129
|
-
self.api_key = opt_api_key
|
|
130
|
-
self.space_key = opt_space_key
|
|
89
|
+
def __init__(self, properties: Optional[ConfluenceProperties] = None) -> None:
|
|
90
|
+
self.properties = properties or ConfluenceProperties()
|
|
131
91
|
|
|
132
92
|
def __enter__(self) -> "ConfluenceSession":
|
|
133
93
|
session = requests.Session()
|
|
134
|
-
if self.user_name:
|
|
135
|
-
session.auth = (self.user_name, self.api_key)
|
|
94
|
+
if self.properties.user_name:
|
|
95
|
+
session.auth = (self.properties.user_name, self.properties.api_key)
|
|
136
96
|
else:
|
|
137
|
-
session.headers.update(
|
|
97
|
+
session.headers.update(
|
|
98
|
+
{"Authorization": f"Bearer {self.properties.api_key}"}
|
|
99
|
+
)
|
|
138
100
|
self.session = ConfluenceSession(
|
|
139
|
-
session,
|
|
101
|
+
session,
|
|
102
|
+
self.properties.domain,
|
|
103
|
+
self.properties.base_path,
|
|
104
|
+
self.properties.space_key,
|
|
140
105
|
)
|
|
141
106
|
return self.session
|
|
142
107
|
|
|
@@ -197,10 +162,10 @@ class ConfluenceSession:
|
|
|
197
162
|
response.raise_for_status()
|
|
198
163
|
|
|
199
164
|
def get_attachment_by_name(
|
|
200
|
-
self, page_id: str, filename: str
|
|
165
|
+
self, page_id: str, filename: str, *, space_key: Optional[str] = None
|
|
201
166
|
) -> ConfluenceAttachment:
|
|
202
167
|
path = f"/content/{page_id}/child/attachment"
|
|
203
|
-
query = {"spaceKey": self.space_key, "filename": filename}
|
|
168
|
+
query = {"spaceKey": space_key or self.space_key, "filename": filename}
|
|
204
169
|
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
205
170
|
|
|
206
171
|
results = typing.cast(List[JsonType], data["results"])
|
|
@@ -218,19 +183,24 @@ class ConfluenceSession:
|
|
|
218
183
|
def upload_attachment(
|
|
219
184
|
self,
|
|
220
185
|
page_id: str,
|
|
221
|
-
attachment_path:
|
|
186
|
+
attachment_path: Path,
|
|
222
187
|
attachment_name: str,
|
|
223
188
|
comment: Optional[str] = None,
|
|
189
|
+
*,
|
|
190
|
+
space_key: Optional[str] = None,
|
|
191
|
+
force: bool = False,
|
|
224
192
|
) -> None:
|
|
225
193
|
content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
|
|
226
194
|
|
|
227
|
-
if not
|
|
195
|
+
if not attachment_path.is_file():
|
|
228
196
|
raise ConfluenceError(f"file not found: {attachment_path}")
|
|
229
197
|
|
|
230
198
|
try:
|
|
231
|
-
attachment = self.get_attachment_by_name(
|
|
199
|
+
attachment = self.get_attachment_by_name(
|
|
200
|
+
page_id, attachment_name, space_key=space_key
|
|
201
|
+
)
|
|
232
202
|
|
|
233
|
-
if attachment.file_size ==
|
|
203
|
+
if not force and attachment.file_size == attachment_path.stat().st_size:
|
|
234
204
|
LOGGER.info("Up-to-date attachment: %s", attachment_name)
|
|
235
205
|
return
|
|
236
206
|
|
|
@@ -271,10 +241,18 @@ class ConfluenceSession:
|
|
|
271
241
|
version = result["version"]["number"] + 1
|
|
272
242
|
|
|
273
243
|
# ensure path component is retained in attachment name
|
|
274
|
-
self._update_attachment(
|
|
244
|
+
self._update_attachment(
|
|
245
|
+
page_id, attachment_id, version, attachment_name, space_key=space_key
|
|
246
|
+
)
|
|
275
247
|
|
|
276
248
|
def _update_attachment(
|
|
277
|
-
self,
|
|
249
|
+
self,
|
|
250
|
+
page_id: str,
|
|
251
|
+
attachment_id: str,
|
|
252
|
+
version: int,
|
|
253
|
+
attachment_title: str,
|
|
254
|
+
*,
|
|
255
|
+
space_key: Optional[str] = None,
|
|
278
256
|
) -> None:
|
|
279
257
|
id = removeprefix(attachment_id, "att")
|
|
280
258
|
path = f"/content/{page_id}/child/attachment/{id}"
|
|
@@ -283,24 +261,30 @@ class ConfluenceSession:
|
|
|
283
261
|
"type": "attachment",
|
|
284
262
|
"status": "current",
|
|
285
263
|
"title": attachment_title,
|
|
286
|
-
"space": {"key": self.space_key},
|
|
264
|
+
"space": {"key": space_key or self.space_key},
|
|
287
265
|
"version": {"minorEdit": True, "number": version},
|
|
288
266
|
}
|
|
289
267
|
|
|
290
268
|
LOGGER.info("Updating attachment: %s", attachment_id)
|
|
291
269
|
self._save(path, data)
|
|
292
270
|
|
|
293
|
-
def get_page_id_by_title(
|
|
271
|
+
def get_page_id_by_title(
|
|
272
|
+
self,
|
|
273
|
+
title: str,
|
|
274
|
+
*,
|
|
275
|
+
space_key: Optional[str] = None,
|
|
276
|
+
) -> str:
|
|
294
277
|
"""
|
|
295
|
-
|
|
278
|
+
Look up a Confluence wiki page ID by title.
|
|
296
279
|
|
|
297
280
|
:param title: The page title.
|
|
298
|
-
:
|
|
281
|
+
:param space_key: The Confluence space key (unless the default space is to be used).
|
|
282
|
+
:returns: Confluence page ID.
|
|
299
283
|
"""
|
|
300
284
|
|
|
301
285
|
LOGGER.info("Looking up page with title: %s", title)
|
|
302
286
|
path = "/content"
|
|
303
|
-
query = {"title": title, "spaceKey": self.space_key}
|
|
287
|
+
query = {"title": title, "spaceKey": space_key or self.space_key}
|
|
304
288
|
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
305
289
|
|
|
306
290
|
results = typing.cast(List[JsonType], data["results"])
|
|
@@ -311,12 +295,23 @@ class ConfluenceSession:
|
|
|
311
295
|
id = typing.cast(str, result["id"])
|
|
312
296
|
return id
|
|
313
297
|
|
|
314
|
-
def get_page(
|
|
298
|
+
def get_page(
|
|
299
|
+
self, page_id: str, *, space_key: Optional[str] = None
|
|
300
|
+
) -> ConfluencePage:
|
|
301
|
+
"""
|
|
302
|
+
Retrieve Confluence wiki page details.
|
|
303
|
+
|
|
304
|
+
:param page_id: The Confluence page ID.
|
|
305
|
+
:param space_key: The Confluence space key (unless the default space is to be used).
|
|
306
|
+
:returns: Confluence page info.
|
|
307
|
+
"""
|
|
308
|
+
|
|
315
309
|
path = f"/content/{page_id}"
|
|
316
310
|
query = {
|
|
317
|
-
"spaceKey": self.space_key,
|
|
311
|
+
"spaceKey": space_key or self.space_key,
|
|
318
312
|
"expand": "body.storage,version",
|
|
319
313
|
}
|
|
314
|
+
|
|
320
315
|
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
321
316
|
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
322
317
|
body = typing.cast(Dict[str, JsonType], data["body"])
|
|
@@ -324,23 +319,43 @@ class ConfluenceSession:
|
|
|
324
319
|
|
|
325
320
|
return ConfluencePage(
|
|
326
321
|
id=page_id,
|
|
322
|
+
space_key=space_key or self.space_key,
|
|
327
323
|
title=typing.cast(str, data["title"]),
|
|
328
324
|
version=typing.cast(int, version["number"]),
|
|
329
325
|
content=typing.cast(str, storage["value"]),
|
|
330
326
|
)
|
|
331
327
|
|
|
332
|
-
def get_page_version(
|
|
328
|
+
def get_page_version(
|
|
329
|
+
self,
|
|
330
|
+
page_id: str,
|
|
331
|
+
*,
|
|
332
|
+
space_key: Optional[str] = None,
|
|
333
|
+
) -> int:
|
|
334
|
+
"""
|
|
335
|
+
Retrieve a Confluence wiki page version.
|
|
336
|
+
|
|
337
|
+
:param page_id: The Confluence page ID.
|
|
338
|
+
:param space_key: The Confluence space key (unless the default space is to be used).
|
|
339
|
+
:returns: Confluence page version.
|
|
340
|
+
"""
|
|
341
|
+
|
|
333
342
|
path = f"/content/{page_id}"
|
|
334
343
|
query = {
|
|
335
|
-
"spaceKey": self.space_key,
|
|
344
|
+
"spaceKey": space_key or self.space_key,
|
|
336
345
|
"expand": "version",
|
|
337
346
|
}
|
|
338
347
|
data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
|
|
339
348
|
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
340
349
|
return typing.cast(int, version["number"])
|
|
341
350
|
|
|
342
|
-
def update_page(
|
|
343
|
-
|
|
351
|
+
def update_page(
|
|
352
|
+
self,
|
|
353
|
+
page_id: str,
|
|
354
|
+
new_content: str,
|
|
355
|
+
*,
|
|
356
|
+
space_key: Optional[str] = None,
|
|
357
|
+
) -> None:
|
|
358
|
+
page = self.get_page(page_id, space_key=space_key)
|
|
344
359
|
|
|
345
360
|
try:
|
|
346
361
|
old_content = sanitize_confluence(page.content)
|
|
@@ -355,10 +370,89 @@ class ConfluenceSession:
|
|
|
355
370
|
"id": page_id,
|
|
356
371
|
"type": "page",
|
|
357
372
|
"title": page.title, # title needs to be unique within a space so the original title is maintained
|
|
358
|
-
"space": {"key": self.space_key},
|
|
373
|
+
"space": {"key": space_key or self.space_key},
|
|
359
374
|
"body": {"storage": {"value": new_content, "representation": "storage"}},
|
|
360
375
|
"version": {"minorEdit": True, "number": page.version + 1},
|
|
361
376
|
}
|
|
362
377
|
|
|
363
378
|
LOGGER.info("Updating page: %s", page_id)
|
|
364
379
|
self._save(path, data)
|
|
380
|
+
|
|
381
|
+
def create_page(
|
|
382
|
+
self,
|
|
383
|
+
parent_page_id: str,
|
|
384
|
+
title: str,
|
|
385
|
+
new_content: str,
|
|
386
|
+
*,
|
|
387
|
+
space_key: Optional[str] = None,
|
|
388
|
+
) -> ConfluencePage:
|
|
389
|
+
path = "/content/"
|
|
390
|
+
query = {
|
|
391
|
+
"type": "page",
|
|
392
|
+
"title": title,
|
|
393
|
+
"space": {"key": space_key or self.space_key},
|
|
394
|
+
"body": {"storage": {"value": new_content, "representation": "storage"}},
|
|
395
|
+
"ancestors": [{"type": "page", "id": parent_page_id}],
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
LOGGER.info("Creating page: %s", title)
|
|
399
|
+
|
|
400
|
+
url = self._build_url(path)
|
|
401
|
+
response = self.session.post(
|
|
402
|
+
url,
|
|
403
|
+
data=json.dumps(query),
|
|
404
|
+
headers={"Content-Type": "application/json"},
|
|
405
|
+
)
|
|
406
|
+
response.raise_for_status()
|
|
407
|
+
|
|
408
|
+
data = typing.cast(Dict[str, JsonType], response.json())
|
|
409
|
+
version = typing.cast(Dict[str, JsonType], data["version"])
|
|
410
|
+
body = typing.cast(Dict[str, JsonType], data["body"])
|
|
411
|
+
storage = typing.cast(Dict[str, JsonType], body["storage"])
|
|
412
|
+
|
|
413
|
+
return ConfluencePage(
|
|
414
|
+
id=typing.cast(str, data["id"]),
|
|
415
|
+
space_key=space_key or self.space_key,
|
|
416
|
+
title=typing.cast(str, data["title"]),
|
|
417
|
+
version=typing.cast(int, version["number"]),
|
|
418
|
+
content=typing.cast(str, storage["value"]),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def page_exists(
|
|
422
|
+
self, title: str, *, space_key: Optional[str] = None
|
|
423
|
+
) -> Optional[str]:
|
|
424
|
+
path = "/content"
|
|
425
|
+
query = {
|
|
426
|
+
"type": "page",
|
|
427
|
+
"title": title,
|
|
428
|
+
"spaceKey": space_key or self.space_key,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
LOGGER.info("Checking if page exists with title: %s", title)
|
|
432
|
+
|
|
433
|
+
url = self._build_url(path)
|
|
434
|
+
response = self.session.get(
|
|
435
|
+
url, params=query, headers={"Content-Type": "application/json"}
|
|
436
|
+
)
|
|
437
|
+
response.raise_for_status()
|
|
438
|
+
|
|
439
|
+
data = typing.cast(Dict[str, JsonType], response.json())
|
|
440
|
+
results = typing.cast(List, data["results"])
|
|
441
|
+
|
|
442
|
+
if len(results) == 1:
|
|
443
|
+
page_info = typing.cast(Dict[str, JsonType], results[0])
|
|
444
|
+
return typing.cast(str, page_info["id"])
|
|
445
|
+
else:
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
def get_or_create_page(
|
|
449
|
+
self, title: str, parent_id: str, *, space_key: Optional[str] = None
|
|
450
|
+
) -> ConfluencePage:
|
|
451
|
+
page_id = self.page_exists(title)
|
|
452
|
+
|
|
453
|
+
if page_id is not None:
|
|
454
|
+
LOGGER.debug("Retrieving existing page: %d", page_id)
|
|
455
|
+
return self.get_page(page_id)
|
|
456
|
+
else:
|
|
457
|
+
LOGGER.debug("Creating new page with title: %s", title)
|
|
458
|
+
return self.create_page(parent_id, title, "", space_key=space_key)
|
md2conf/application.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os.path
|
|
3
|
-
from
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Optional
|
|
4
5
|
|
|
5
6
|
from .api import ConfluenceSession
|
|
6
7
|
from .converter import (
|
|
7
8
|
ConfluenceDocument,
|
|
8
9
|
ConfluenceDocumentOptions,
|
|
9
10
|
ConfluencePageMetadata,
|
|
10
|
-
|
|
11
|
+
attachment_name,
|
|
12
|
+
extract_qualified_id,
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -25,51 +27,42 @@ class Application:
|
|
|
25
27
|
self.api = api
|
|
26
28
|
self.options = options
|
|
27
29
|
|
|
28
|
-
def synchronize(self, path:
|
|
30
|
+
def synchronize(self, path: Path) -> None:
|
|
29
31
|
"Synchronizes a single Markdown page or a directory of Markdown pages."
|
|
30
32
|
|
|
31
|
-
if
|
|
33
|
+
if path.is_dir():
|
|
32
34
|
self.synchronize_directory(path)
|
|
33
|
-
elif
|
|
35
|
+
elif path.is_file():
|
|
34
36
|
self.synchronize_page(path)
|
|
35
37
|
else:
|
|
36
38
|
raise ValueError(f"expected: valid file or directory path; got: {path}")
|
|
37
39
|
|
|
38
|
-
def synchronize_page(self, page_path:
|
|
40
|
+
def synchronize_page(self, page_path: Path) -> None:
|
|
39
41
|
"Synchronizes a single Markdown page with Confluence."
|
|
40
42
|
|
|
41
|
-
self._synchronize_page(page_path,
|
|
43
|
+
self._synchronize_page(page_path, {})
|
|
42
44
|
|
|
43
|
-
def synchronize_directory(self,
|
|
45
|
+
def synchronize_directory(self, local_dir: Path) -> None:
|
|
44
46
|
"Synchronizes a directory of Markdown pages with Confluence."
|
|
45
47
|
|
|
46
|
-
page_metadata: Dict[
|
|
47
|
-
LOGGER.info(f"Synchronizing directory: {
|
|
48
|
+
page_metadata: Dict[Path, ConfluencePageMetadata] = {}
|
|
49
|
+
LOGGER.info(f"Synchronizing directory: {local_dir}")
|
|
48
50
|
|
|
49
51
|
# Step 1: build index of all page metadata
|
|
50
|
-
|
|
52
|
+
# NOTE: Pathlib.walk() is implemented only in Python 3.12+
|
|
53
|
+
# so sticking for old os.walk
|
|
54
|
+
for root, directories, files in os.walk(local_dir):
|
|
51
55
|
for file_name in files:
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
# Reconstitute Path object back
|
|
57
|
+
docfile = (Path(root) / file_name).absolute()
|
|
58
|
+
|
|
59
|
+
# Skip non-markdown files
|
|
60
|
+
if docfile.suffix.lower() != ".md":
|
|
55
61
|
continue
|
|
62
|
+
metadata = self._get_or_create_page(docfile)
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
with open(absolute_path, "r") as f:
|
|
60
|
-
document = f.read()
|
|
61
|
-
|
|
62
|
-
id, document = extract_page_id(document)
|
|
63
|
-
confluence_page = self.api.get_page(id.page_id)
|
|
64
|
-
metadata = ConfluencePageMetadata(
|
|
65
|
-
domain=self.api.domain,
|
|
66
|
-
base_path=self.api.base_path,
|
|
67
|
-
page_id=id.page_id,
|
|
68
|
-
space_key=id.space_key or self.api.space_key,
|
|
69
|
-
title=confluence_page.title or "",
|
|
70
|
-
)
|
|
71
|
-
LOGGER.debug(f"indexed {absolute_path} with metadata: {metadata}")
|
|
72
|
-
page_metadata[absolute_path] = metadata
|
|
64
|
+
LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
|
|
65
|
+
page_metadata[docfile] = metadata
|
|
73
66
|
|
|
74
67
|
LOGGER.info(f"indexed {len(page_metadata)} pages")
|
|
75
68
|
|
|
@@ -79,10 +72,10 @@ class Application:
|
|
|
79
72
|
|
|
80
73
|
def _synchronize_page(
|
|
81
74
|
self,
|
|
82
|
-
page_path:
|
|
83
|
-
page_metadata: Dict[
|
|
75
|
+
page_path: Path,
|
|
76
|
+
page_metadata: Dict[Path, ConfluencePageMetadata],
|
|
84
77
|
) -> None:
|
|
85
|
-
base_path =
|
|
78
|
+
base_path = page_path.parent
|
|
86
79
|
|
|
87
80
|
LOGGER.info(f"Synchronizing page: {page_path}")
|
|
88
81
|
document = ConfluenceDocument(page_path, self.options, page_metadata)
|
|
@@ -93,12 +86,69 @@ class Application:
|
|
|
93
86
|
else:
|
|
94
87
|
self._update_document(document, base_path)
|
|
95
88
|
|
|
96
|
-
def
|
|
89
|
+
def _get_or_create_page(
|
|
90
|
+
self, absolute_path: Path, title: Optional[str] = None
|
|
91
|
+
) -> ConfluencePageMetadata:
|
|
92
|
+
"""
|
|
93
|
+
Creates a new Confluence page if no page is linked in the Markdown document.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# parse file
|
|
97
|
+
with open(absolute_path, "r") as f:
|
|
98
|
+
document = f.read()
|
|
99
|
+
|
|
100
|
+
qualified_id, document = extract_qualified_id(document)
|
|
101
|
+
if qualified_id is not None:
|
|
102
|
+
confluence_page = self.api.get_page(
|
|
103
|
+
qualified_id.page_id, space_key=qualified_id.space_key
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
if self.options.root_page_id is None:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"expected: Confluence page ID to act as parent for Markdown files with no linked Confluence page"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# use file name without extension if no title is supplied
|
|
112
|
+
if title is None:
|
|
113
|
+
title = absolute_path.stem
|
|
114
|
+
|
|
115
|
+
confluence_page = self.api.get_or_create_page(
|
|
116
|
+
title, self.options.root_page_id
|
|
117
|
+
)
|
|
118
|
+
self._update_markdown(
|
|
119
|
+
absolute_path,
|
|
120
|
+
document,
|
|
121
|
+
confluence_page.id,
|
|
122
|
+
confluence_page.space_key,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return ConfluencePageMetadata(
|
|
126
|
+
domain=self.api.domain,
|
|
127
|
+
base_path=self.api.base_path,
|
|
128
|
+
page_id=confluence_page.id,
|
|
129
|
+
space_key=confluence_page.space_key or self.api.space_key,
|
|
130
|
+
title=confluence_page.title or "",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
|
|
97
134
|
for image in document.images:
|
|
98
135
|
self.api.upload_attachment(
|
|
99
|
-
document.id.page_id,
|
|
136
|
+
document.id.page_id, base_path / image, attachment_name(image), ""
|
|
100
137
|
)
|
|
101
138
|
|
|
102
139
|
content = document.xhtml()
|
|
103
140
|
LOGGER.debug(f"generated Confluence Storage Format document:\n{content}")
|
|
104
141
|
self.api.update_page(document.id.page_id, content)
|
|
142
|
+
|
|
143
|
+
def _update_markdown(
|
|
144
|
+
self,
|
|
145
|
+
path: Path,
|
|
146
|
+
document: str,
|
|
147
|
+
page_id: str,
|
|
148
|
+
space_key: Optional[str],
|
|
149
|
+
) -> None:
|
|
150
|
+
with open(path, "w") as file:
|
|
151
|
+
file.write(f"<!-- confluence-page-id: {page_id} -->\n")
|
|
152
|
+
if space_key:
|
|
153
|
+
file.write(f"<!-- confluence-space-key: {space_key} -->\n")
|
|
154
|
+
file.write(document)
|
md2conf/converter.py
CHANGED
|
@@ -207,28 +207,38 @@ class NodeVisitor:
|
|
|
207
207
|
pass
|
|
208
208
|
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
@dataclass
|
|
211
|
+
class ConfluenceConverterOptions:
|
|
212
|
+
"""
|
|
213
|
+
Options for converting an HTML tree into Confluence storage format.
|
|
214
|
+
|
|
215
|
+
:param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
|
|
216
|
+
plain text; when false, raise an exception.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
ignore_invalid_url: bool = False
|
|
213
220
|
|
|
214
221
|
|
|
215
222
|
class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
216
223
|
"Transforms a plain HTML tree into the Confluence storage format."
|
|
217
224
|
|
|
218
|
-
|
|
219
|
-
|
|
225
|
+
options: ConfluenceConverterOptions
|
|
226
|
+
path: pathlib.Path
|
|
227
|
+
base_path: pathlib.Path
|
|
220
228
|
links: List[str]
|
|
221
229
|
images: List[str]
|
|
222
|
-
page_metadata: Dict[
|
|
230
|
+
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
|
|
223
231
|
|
|
224
232
|
def __init__(
|
|
225
233
|
self,
|
|
226
|
-
|
|
227
|
-
|
|
234
|
+
options: ConfluenceConverterOptions,
|
|
235
|
+
path: pathlib.Path,
|
|
236
|
+
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
|
|
228
237
|
) -> None:
|
|
229
238
|
super().__init__()
|
|
239
|
+
self.options = options
|
|
230
240
|
self.path = path
|
|
231
|
-
self.base_path =
|
|
241
|
+
self.base_path = path.parent
|
|
232
242
|
self.links = []
|
|
233
243
|
self.images = []
|
|
234
244
|
self.page_metadata = page_metadata
|
|
@@ -255,15 +265,27 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
255
265
|
# convert the relative URL to absolute URL based on the base path value, then look up
|
|
256
266
|
# the absolute path in the page metadata dictionary to discover the relative path
|
|
257
267
|
# within Confluence that should be used
|
|
258
|
-
absolute_path =
|
|
259
|
-
if not absolute_path.startswith(self.base_path):
|
|
260
|
-
|
|
268
|
+
absolute_path = (self.base_path / relative_url.path).absolute()
|
|
269
|
+
if not str(absolute_path).startswith(str(self.base_path)):
|
|
270
|
+
msg = f"relative URL {url} points to outside base path: {self.base_path}"
|
|
271
|
+
if self.options.ignore_invalid_url:
|
|
272
|
+
LOGGER.warning(msg)
|
|
273
|
+
anchor.attrib.pop("href")
|
|
274
|
+
return
|
|
275
|
+
else:
|
|
276
|
+
raise DocumentError(msg)
|
|
261
277
|
|
|
262
278
|
relative_path = os.path.relpath(absolute_path, self.base_path)
|
|
263
279
|
|
|
264
280
|
link_metadata = self.page_metadata.get(absolute_path)
|
|
265
281
|
if link_metadata is None:
|
|
266
|
-
|
|
282
|
+
msg = f"unable to find matching page for URL: {url}"
|
|
283
|
+
if self.options.ignore_invalid_url:
|
|
284
|
+
LOGGER.warning(msg)
|
|
285
|
+
anchor.attrib.pop("href")
|
|
286
|
+
return
|
|
287
|
+
else:
|
|
288
|
+
raise DocumentError(msg)
|
|
267
289
|
|
|
268
290
|
LOGGER.debug(
|
|
269
291
|
f"found link to page {relative_path} with metadata: {link_metadata}"
|
|
@@ -287,10 +309,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
287
309
|
path: str = image.attrib["src"]
|
|
288
310
|
|
|
289
311
|
# prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
|
|
290
|
-
if path and is_relative_url(path)
|
|
291
|
-
|
|
292
|
-
if
|
|
293
|
-
|
|
312
|
+
if path and is_relative_url(path):
|
|
313
|
+
relative_path = pathlib.Path(path)
|
|
314
|
+
if (
|
|
315
|
+
relative_path.suffix == ".svg"
|
|
316
|
+
and (self.base_path / relative_path.with_suffix(".png")).exists()
|
|
317
|
+
):
|
|
318
|
+
path = str(relative_path.with_suffix(".png"))
|
|
294
319
|
|
|
295
320
|
self.images.append(path)
|
|
296
321
|
caption = image.attrib["alt"]
|
|
@@ -300,7 +325,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
300
325
|
ET.QName(namespaces["ac"], "align"): "center",
|
|
301
326
|
ET.QName(namespaces["ac"], "layout"): "center",
|
|
302
327
|
},
|
|
303
|
-
RI(
|
|
328
|
+
RI(
|
|
329
|
+
"attachment",
|
|
330
|
+
{ET.QName(namespaces["ri"], "filename"): attachment_name(path)},
|
|
331
|
+
),
|
|
304
332
|
AC("caption", HTML.p(caption)),
|
|
305
333
|
)
|
|
306
334
|
|
|
@@ -364,6 +392,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
364
392
|
if class_name is None:
|
|
365
393
|
raise DocumentError(f"unsupported admonition label: {class_list}")
|
|
366
394
|
|
|
395
|
+
for e in elem:
|
|
396
|
+
self.visit(e)
|
|
397
|
+
|
|
367
398
|
# <p class="admonition-title">Note</p>
|
|
368
399
|
if "admonition-title" in elem[0].attrib.get("class", "").split(" "):
|
|
369
400
|
content = [
|
|
@@ -464,12 +495,11 @@ class ConfluenceQualifiedID:
|
|
|
464
495
|
space_key: Optional[str] = None
|
|
465
496
|
|
|
466
497
|
|
|
467
|
-
def
|
|
498
|
+
def extract_qualified_id(string: str) -> Tuple[Optional[ConfluenceQualifiedID], str]:
|
|
468
499
|
page_id, string = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", string)
|
|
500
|
+
|
|
469
501
|
if page_id is None:
|
|
470
|
-
|
|
471
|
-
"Markdown document has no Confluence page ID associated with it"
|
|
472
|
-
)
|
|
502
|
+
return None, string
|
|
473
503
|
|
|
474
504
|
# extract Confluence space key
|
|
475
505
|
space_key, string = extract_value(
|
|
@@ -484,10 +514,14 @@ class ConfluenceDocumentOptions:
|
|
|
484
514
|
"""
|
|
485
515
|
Options that control the generated page content.
|
|
486
516
|
|
|
517
|
+
:param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
|
|
518
|
+
plain text; when false, raise an exception.
|
|
487
519
|
:param show_generated: Whether to display a prompt "This page has been generated with a tool."
|
|
488
520
|
"""
|
|
489
521
|
|
|
522
|
+
ignore_invalid_url: bool = False
|
|
490
523
|
generated_by: Optional[str] = "This page has been generated with a tool."
|
|
524
|
+
root_page_id: Optional[str] = None
|
|
491
525
|
|
|
492
526
|
|
|
493
527
|
class ConfluenceDocument:
|
|
@@ -500,24 +534,33 @@ class ConfluenceDocument:
|
|
|
500
534
|
|
|
501
535
|
def __init__(
|
|
502
536
|
self,
|
|
503
|
-
path:
|
|
537
|
+
path: pathlib.Path,
|
|
504
538
|
options: ConfluenceDocumentOptions,
|
|
505
|
-
page_metadata: Dict[
|
|
539
|
+
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
|
|
506
540
|
) -> None:
|
|
507
541
|
self.options = options
|
|
508
|
-
path =
|
|
542
|
+
path = path.absolute()
|
|
509
543
|
|
|
510
544
|
with open(path, "r") as f:
|
|
511
|
-
|
|
545
|
+
text = f.read()
|
|
512
546
|
|
|
513
547
|
# extract Confluence page ID
|
|
514
|
-
|
|
548
|
+
qualified_id, text = extract_qualified_id(text)
|
|
549
|
+
if qualified_id is None:
|
|
550
|
+
raise ValueError("missing Confluence page ID")
|
|
551
|
+
self.id = qualified_id
|
|
515
552
|
|
|
516
553
|
# extract 'generated-by' tag text
|
|
517
|
-
generated_by_tag,
|
|
518
|
-
r"<!--\s+generated-by:\s*(.*)\s+-->",
|
|
554
|
+
generated_by_tag, text = extract_value(
|
|
555
|
+
r"<!--\s+generated-by:\s*(.*)\s+-->", text
|
|
519
556
|
)
|
|
520
557
|
|
|
558
|
+
# extract frontmatter
|
|
559
|
+
frontmatter, text = extract_value(r"(?ms)\A---$(.+?)^---$", text)
|
|
560
|
+
|
|
561
|
+
# convert to HTML
|
|
562
|
+
html = markdown_to_html(text)
|
|
563
|
+
|
|
521
564
|
# parse Markdown document
|
|
522
565
|
if self.options.generated_by is not None:
|
|
523
566
|
generated_by = self.options.generated_by
|
|
@@ -534,7 +577,13 @@ class ConfluenceDocument:
|
|
|
534
577
|
content = [html]
|
|
535
578
|
self.root = elements_from_strings(content)
|
|
536
579
|
|
|
537
|
-
converter = ConfluenceStorageFormatConverter(
|
|
580
|
+
converter = ConfluenceStorageFormatConverter(
|
|
581
|
+
ConfluenceConverterOptions(
|
|
582
|
+
ignore_invalid_url=self.options.ignore_invalid_url
|
|
583
|
+
),
|
|
584
|
+
path,
|
|
585
|
+
page_metadata,
|
|
586
|
+
)
|
|
538
587
|
converter.visit(self.root)
|
|
539
588
|
self.links = converter.links
|
|
540
589
|
self.images = converter.images
|
|
@@ -543,6 +592,18 @@ class ConfluenceDocument:
|
|
|
543
592
|
return _content_to_string(self.root)
|
|
544
593
|
|
|
545
594
|
|
|
595
|
+
def attachment_name(name: str) -> str:
|
|
596
|
+
"""
|
|
597
|
+
Safe name for use with attachment uploads.
|
|
598
|
+
|
|
599
|
+
Allowed characters:
|
|
600
|
+
* Alphanumeric characters: 0-9, a-z, A-Z
|
|
601
|
+
* Special characters: hyphen (-), underscore (_), period (.)
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
return re.sub(r"[^\-0-9A-Za-z_.]", "_", name)
|
|
605
|
+
|
|
606
|
+
|
|
546
607
|
def sanitize_confluence(html: str) -> str:
|
|
547
608
|
"Generates a sanitized version of a Confluence storage format XHTML document with no volatile attributes."
|
|
548
609
|
|
md2conf/processor.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
from .converter import (
|
|
7
|
+
ConfluenceDocument,
|
|
8
|
+
ConfluenceDocumentOptions,
|
|
9
|
+
ConfluencePageMetadata,
|
|
10
|
+
extract_qualified_id,
|
|
11
|
+
)
|
|
12
|
+
from .properties import ConfluenceProperties
|
|
13
|
+
|
|
14
|
+
LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Processor:
|
|
18
|
+
options: ConfluenceDocumentOptions
|
|
19
|
+
properties: ConfluenceProperties
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self, options: ConfluenceDocumentOptions, properties: ConfluenceProperties
|
|
23
|
+
) -> None:
|
|
24
|
+
self.options = options
|
|
25
|
+
self.properties = properties
|
|
26
|
+
|
|
27
|
+
def process(self, path: Path) -> None:
|
|
28
|
+
"Processes a single Markdown file or a directory of Markdown files."
|
|
29
|
+
|
|
30
|
+
if path.is_dir():
|
|
31
|
+
self.process_directory(path)
|
|
32
|
+
elif path.is_file():
|
|
33
|
+
self.process_page(path, {})
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError(f"expected: valid file or directory path; got: {path}")
|
|
36
|
+
|
|
37
|
+
def process_directory(self, local_dir: Path) -> None:
|
|
38
|
+
"Recursively scans a directory hierarchy for Markdown files."
|
|
39
|
+
|
|
40
|
+
page_metadata: Dict[Path, ConfluencePageMetadata] = {}
|
|
41
|
+
LOGGER.info(f"Synchronizing directory: {local_dir}")
|
|
42
|
+
|
|
43
|
+
# Step 1: build index of all page metadata
|
|
44
|
+
# NOTE: Pathlib.walk() is implemented only in Python 3.12+
|
|
45
|
+
# so sticking for old os.walk
|
|
46
|
+
for root, directories, files in os.walk(local_dir):
|
|
47
|
+
for file_name in files:
|
|
48
|
+
# Reconstitute Path object back
|
|
49
|
+
docfile = (Path(root) / file_name).absolute()
|
|
50
|
+
|
|
51
|
+
# Skip non-markdown files
|
|
52
|
+
if docfile.suffix.lower() != ".md":
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
metadata = self._get_page(docfile)
|
|
56
|
+
LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
|
|
57
|
+
page_metadata[docfile] = metadata
|
|
58
|
+
|
|
59
|
+
LOGGER.info(f"indexed {len(page_metadata)} pages")
|
|
60
|
+
|
|
61
|
+
# Step 2: Convert each page
|
|
62
|
+
for page_path in page_metadata.keys():
|
|
63
|
+
self.process_page(page_path, page_metadata)
|
|
64
|
+
|
|
65
|
+
def process_page(
|
|
66
|
+
self, path: Path, page_metadata: Dict[Path, ConfluencePageMetadata]
|
|
67
|
+
) -> None:
|
|
68
|
+
"Processes a single Markdown file."
|
|
69
|
+
|
|
70
|
+
document = ConfluenceDocument(path, self.options, page_metadata)
|
|
71
|
+
content = document.xhtml()
|
|
72
|
+
with open(path.with_suffix(".csf"), "w") as f:
|
|
73
|
+
f.write(content)
|
|
74
|
+
|
|
75
|
+
def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
|
|
76
|
+
"Extracts metadata from a Markdown file."
|
|
77
|
+
|
|
78
|
+
with open(absolute_path, "r") as f:
|
|
79
|
+
document = f.read()
|
|
80
|
+
|
|
81
|
+
qualified_id, document = extract_qualified_id(document)
|
|
82
|
+
if qualified_id is None:
|
|
83
|
+
raise ValueError("required: page ID for local output")
|
|
84
|
+
|
|
85
|
+
return ConfluencePageMetadata(
|
|
86
|
+
domain=self.properties.domain,
|
|
87
|
+
base_path=self.properties.base_path,
|
|
88
|
+
page_id=qualified_id.page_id,
|
|
89
|
+
space_key=qualified_id.space_key or self.properties.space_key,
|
|
90
|
+
title="",
|
|
91
|
+
)
|
md2conf/properties.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfluenceError(RuntimeError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfluenceProperties:
|
|
10
|
+
domain: str
|
|
11
|
+
base_path: str
|
|
12
|
+
space_key: str
|
|
13
|
+
user_name: Optional[str]
|
|
14
|
+
api_key: str
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
domain: Optional[str] = None,
|
|
19
|
+
base_path: Optional[str] = None,
|
|
20
|
+
user_name: Optional[str] = None,
|
|
21
|
+
api_key: Optional[str] = None,
|
|
22
|
+
space_key: Optional[str] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
|
|
25
|
+
opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
|
|
26
|
+
opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
|
|
27
|
+
opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
|
|
28
|
+
opt_space_key = space_key or os.getenv("CONFLUENCE_SPACE_KEY")
|
|
29
|
+
|
|
30
|
+
if not opt_domain:
|
|
31
|
+
raise ConfluenceError("Confluence domain not specified")
|
|
32
|
+
if not opt_base_path:
|
|
33
|
+
opt_base_path = "/wiki/"
|
|
34
|
+
if not opt_api_key:
|
|
35
|
+
raise ConfluenceError("Confluence API key not specified")
|
|
36
|
+
if not opt_space_key:
|
|
37
|
+
raise ConfluenceError("Confluence space key not specified")
|
|
38
|
+
|
|
39
|
+
if opt_domain.startswith(("http://", "https://")) or opt_domain.endswith("/"):
|
|
40
|
+
raise ConfluenceError(
|
|
41
|
+
"Confluence domain looks like a URL; only host name required"
|
|
42
|
+
)
|
|
43
|
+
if not opt_base_path.startswith("/") or not opt_base_path.endswith("/"):
|
|
44
|
+
raise ConfluenceError("Confluence base path must start and end with a '/'")
|
|
45
|
+
|
|
46
|
+
self.domain = opt_domain
|
|
47
|
+
self.base_path = opt_base_path
|
|
48
|
+
self.user_name = opt_user_name
|
|
49
|
+
self.api_key = opt_api_key
|
|
50
|
+
self.space_key = opt_space_key
|
|
51
|
+
self.space_key = opt_space_key
|
|
52
|
+
self.space_key = opt_space_key
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
md2conf/__init__.py,sha256=87hc-oGK6Iy7HpMb4r0ujxMFZTbmrMValBsbRy1xySA,403
|
|
2
|
-
md2conf/__main__.py,sha256=vqSQ1jYCDXcPw7xG6MxbY5qDgwJxFBYPgp25CqniiNE,2639
|
|
3
|
-
md2conf/api.py,sha256=qomqdn8sztLe5jBaiqbA-YqSt7W3Kp_2K4-amJhMYK4,11946
|
|
4
|
-
md2conf/application.py,sha256=DOaPHuvWQNHmLuTWnTeLvWvgn4vvy3NN4eM-UGND2Qs,3670
|
|
5
|
-
md2conf/converter.py,sha256=WDm_MlzGf3suTvSbbngUjODigWrlP8A6PpqqFRT0NZY,16410
|
|
6
|
-
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
7
|
-
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
markdown_to_confluence-0.1.10.dist-info/LICENSE,sha256=W_F6JttWaUq8-m1T7FrjMU0PYCTHYC-H4DUaPNZ7YGc,1077
|
|
9
|
-
markdown_to_confluence-0.1.10.dist-info/METADATA,sha256=z4wxlc22eWiujPC-09-p9dE-OnrzEH4FAXKgIIC6MIo,7468
|
|
10
|
-
markdown_to_confluence-0.1.10.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
|
|
11
|
-
markdown_to_confluence-0.1.10.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
12
|
-
markdown_to_confluence-0.1.10.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
13
|
-
markdown_to_confluence-0.1.10.dist-info/RECORD,,
|
{markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/top_level.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.1.10.dist-info → markdown_to_confluence-0.1.12.dist-info}/zip-safe
RENAMED
|
File without changes
|