markdown-to-confluence 0.1.11__tar.gz → 0.1.12__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.1.11 → markdown_to_confluence-0.1.12}/PKG-INFO +18 -13
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/README.md +10 -5
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/PKG-INFO +18 -13
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/SOURCES.txt +4 -1
- markdown_to_confluence-0.1.12/markdown_to_confluence.egg-info/entry_points.txt +2 -0
- markdown_to_confluence-0.1.12/markdown_to_confluence.egg-info/requires.txt +7 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/__init__.py +1 -1
- markdown_to_confluence-0.1.12/md2conf/__main__.py +140 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/api.py +5 -5
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/application.py +28 -24
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/converter.py +48 -26
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/processor.py +22 -20
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/properties.py +0 -1
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/setup.cfg +11 -7
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/tests/test_api.py +33 -23
- markdown_to_confluence-0.1.12/tests/test_conversion.py +58 -0
- markdown_to_confluence-0.1.12/tests/test_processor.py +64 -0
- markdown-to-confluence-0.1.11/markdown_to_confluence.egg-info/requires.txt +0 -7
- markdown-to-confluence-0.1.11/md2conf/__main__.py +0 -129
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/LICENSE +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/top_level.txt +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/markdown_to_confluence.egg-info/zip-safe +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/entities.dtd +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/md2conf/py.typed +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/pyproject.toml +0 -0
- {markdown-to-confluence-0.1.11 → markdown_to_confluence-0.1.12}/setup.py +0 -0
|
@@ -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,18 +139,18 @@ 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] [-l {debug,info,warning,error,critical}]
|
|
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
|
|
@@ -156,8 +159,10 @@ options:
|
|
|
156
159
|
Confluence space key for pages to be published. If omitted, will default to user space.
|
|
157
160
|
-l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
|
|
158
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.
|
|
159
163
|
--generated-by GENERATED_BY
|
|
160
164
|
Add prompt to pages (default: 'This page has been generated with a tool.').
|
|
161
165
|
--no-generated-by Do not add 'generated by a tool' prompt to pages.
|
|
162
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
|
```
|
|
@@ -5,6 +5,7 @@ Contributors to software projects typically write documentation in Markdown form
|
|
|
5
5
|
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.
|
|
6
6
|
|
|
7
7
|
This Python package
|
|
8
|
+
|
|
8
9
|
* parses Markdown files,
|
|
9
10
|
* converts Markdown content into the Confluence Storage Format (XHTML),
|
|
10
11
|
* invokes Confluence API endpoints to upload images and content.
|
|
@@ -23,6 +24,7 @@ This Python package
|
|
|
23
24
|
## Getting started
|
|
24
25
|
|
|
25
26
|
In order to get started, you will need
|
|
27
|
+
|
|
26
28
|
* your organization domain name (e.g. `instructure.atlassian.net`),
|
|
27
29
|
* base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
|
|
28
30
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
|
|
@@ -31,7 +33,7 @@ In order to get started, you will need
|
|
|
31
33
|
|
|
32
34
|
### Obtaining an API token
|
|
33
35
|
|
|
34
|
-
1. Log in to https://id.atlassian.com/manage/api-tokens
|
|
36
|
+
1. Log in to <https://id.atlassian.com/manage/api-tokens>.
|
|
35
37
|
2. Click *Create API token*.
|
|
36
38
|
3. From the dialog that appears, enter a memorable and concise *Label* for your token and click *Create*.
|
|
37
39
|
4. Click *Copy to clipboard*, then paste the token to your script, or elsewhere to save.
|
|
@@ -39,6 +41,7 @@ In order to get started, you will need
|
|
|
39
41
|
### Setting up the environment
|
|
40
42
|
|
|
41
43
|
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):
|
|
44
|
+
|
|
42
45
|
```bash
|
|
43
46
|
export CONFLUENCE_DOMAIN='instructure.atlassian.net'
|
|
44
47
|
export CONFLUENCE_PATH='/wiki/'
|
|
@@ -105,18 +108,18 @@ Use the `--help` switch to get a full list of supported command-line options:
|
|
|
105
108
|
|
|
106
109
|
```console
|
|
107
110
|
$ python3 -m md2conf --help
|
|
108
|
-
usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}]
|
|
109
|
-
[--
|
|
111
|
+
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]
|
|
112
|
+
[--no-generated-by] [--ignore-invalid-url] [--local]
|
|
110
113
|
mdpath
|
|
111
114
|
|
|
112
115
|
positional arguments:
|
|
113
116
|
mdpath Path to Markdown file or directory to convert and publish.
|
|
114
117
|
|
|
115
|
-
|
|
118
|
+
optional arguments:
|
|
116
119
|
-h, --help show this help message and exit
|
|
117
120
|
-d DOMAIN, --domain DOMAIN
|
|
118
121
|
Confluence organization domain.
|
|
119
|
-
-p PATH, --path PATH Base path for
|
|
122
|
+
-p PATH, --path PATH Base path for Confluence (default: '/wiki/').
|
|
120
123
|
-u USERNAME, --username USERNAME
|
|
121
124
|
Confluence user name.
|
|
122
125
|
-a APIKEY, --apikey APIKEY
|
|
@@ -125,8 +128,10 @@ options:
|
|
|
125
128
|
Confluence space key for pages to be published. If omitted, will default to user space.
|
|
126
129
|
-l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
|
|
127
130
|
Use this option to set the log verbosity.
|
|
131
|
+
-r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
|
|
128
132
|
--generated-by GENERATED_BY
|
|
129
133
|
Add prompt to pages (default: 'This page has been generated with a tool.').
|
|
130
134
|
--no-generated-by Do not add 'generated by a tool' prompt to pages.
|
|
131
135
|
--ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
|
|
136
|
+
--local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
|
|
132
137
|
```
|
|
@@ -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,18 +139,18 @@ 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] [-l {debug,info,warning,error,critical}]
|
|
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
|
|
@@ -156,8 +159,10 @@ options:
|
|
|
156
159
|
Confluence space key for pages to be published. If omitted, will default to user space.
|
|
157
160
|
-l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
|
|
158
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.
|
|
159
163
|
--generated-by GENERATED_BY
|
|
160
164
|
Add prompt to pages (default: 'This page has been generated with a tool.').
|
|
161
165
|
--no-generated-by Do not add 'generated by a tool' prompt to pages.
|
|
162
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
|
```
|
|
@@ -6,6 +6,7 @@ setup.py
|
|
|
6
6
|
markdown_to_confluence.egg-info/PKG-INFO
|
|
7
7
|
markdown_to_confluence.egg-info/SOURCES.txt
|
|
8
8
|
markdown_to_confluence.egg-info/dependency_links.txt
|
|
9
|
+
markdown_to_confluence.egg-info/entry_points.txt
|
|
9
10
|
markdown_to_confluence.egg-info/requires.txt
|
|
10
11
|
markdown_to_confluence.egg-info/top_level.txt
|
|
11
12
|
markdown_to_confluence.egg-info/zip-safe
|
|
@@ -18,4 +19,6 @@ md2conf/entities.dtd
|
|
|
18
19
|
md2conf/processor.py
|
|
19
20
|
md2conf/properties.py
|
|
20
21
|
md2conf/py.typed
|
|
21
|
-
tests/test_api.py
|
|
22
|
+
tests/test_api.py
|
|
23
|
+
tests/test_conversion.py
|
|
24
|
+
tests/test_processor.py
|
|
@@ -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.1.
|
|
8
|
+
__version__ = "0.1.12"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
10
|
__copyright__ = "Copyright 2022-2024, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os.path
|
|
4
|
+
import sys
|
|
5
|
+
import typing
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .api import ConfluenceAPI
|
|
12
|
+
from .application import Application
|
|
13
|
+
from .converter import ConfluenceDocumentOptions
|
|
14
|
+
from .processor import Processor
|
|
15
|
+
from .properties import ConfluenceProperties
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Arguments(argparse.Namespace):
|
|
19
|
+
mdpath: Path
|
|
20
|
+
domain: str
|
|
21
|
+
path: str
|
|
22
|
+
username: str
|
|
23
|
+
apikey: str
|
|
24
|
+
space: str
|
|
25
|
+
loglevel: str
|
|
26
|
+
ignore_invalid_url: bool
|
|
27
|
+
generated_by: Optional[str]
|
|
28
|
+
|
|
29
|
+
|
|
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
|
+
)
|
|
96
|
+
|
|
97
|
+
args = Arguments()
|
|
98
|
+
parser.parse_args(namespace=args)
|
|
99
|
+
|
|
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)
|
|
103
|
+
|
|
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
|
+
)
|
|
108
|
+
|
|
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:
|
|
120
|
+
try:
|
|
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
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -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
|
|
@@ -184,15 +183,16 @@ class ConfluenceSession:
|
|
|
184
183
|
def upload_attachment(
|
|
185
184
|
self,
|
|
186
185
|
page_id: str,
|
|
187
|
-
attachment_path:
|
|
186
|
+
attachment_path: Path,
|
|
188
187
|
attachment_name: str,
|
|
189
188
|
comment: Optional[str] = None,
|
|
190
189
|
*,
|
|
191
190
|
space_key: Optional[str] = None,
|
|
191
|
+
force: bool = False,
|
|
192
192
|
) -> None:
|
|
193
193
|
content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
|
|
194
194
|
|
|
195
|
-
if not
|
|
195
|
+
if not attachment_path.is_file():
|
|
196
196
|
raise ConfluenceError(f"file not found: {attachment_path}")
|
|
197
197
|
|
|
198
198
|
try:
|
|
@@ -200,7 +200,7 @@ class ConfluenceSession:
|
|
|
200
200
|
page_id, attachment_name, space_key=space_key
|
|
201
201
|
)
|
|
202
202
|
|
|
203
|
-
if attachment.file_size ==
|
|
203
|
+
if not force and attachment.file_size == attachment_path.stat().st_size:
|
|
204
204
|
LOGGER.info("Up-to-date attachment: %s", attachment_name)
|
|
205
205
|
return
|
|
206
206
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os.path
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
from typing import Dict, Optional
|
|
4
5
|
|
|
5
6
|
from .api import ConfluenceSession
|
|
@@ -7,6 +8,7 @@ from .converter import (
|
|
|
7
8
|
ConfluenceDocument,
|
|
8
9
|
ConfluenceDocumentOptions,
|
|
9
10
|
ConfluencePageMetadata,
|
|
11
|
+
attachment_name,
|
|
10
12
|
extract_qualified_id,
|
|
11
13
|
)
|
|
12
14
|
|
|
@@ -25,40 +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
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
|
-
if file_extension.lower() != ".md":
|
|
55
|
-
continue
|
|
56
|
+
# Reconstitute Path object back
|
|
57
|
+
docfile = (Path(root) / file_name).absolute()
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
# Skip non-markdown files
|
|
60
|
+
if docfile.suffix.lower() != ".md":
|
|
61
|
+
continue
|
|
62
|
+
metadata = self._get_or_create_page(docfile)
|
|
59
63
|
|
|
60
|
-
LOGGER.debug(f"indexed {
|
|
61
|
-
page_metadata[
|
|
64
|
+
LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
|
|
65
|
+
page_metadata[docfile] = metadata
|
|
62
66
|
|
|
63
67
|
LOGGER.info(f"indexed {len(page_metadata)} pages")
|
|
64
68
|
|
|
@@ -68,10 +72,10 @@ class Application:
|
|
|
68
72
|
|
|
69
73
|
def _synchronize_page(
|
|
70
74
|
self,
|
|
71
|
-
page_path:
|
|
72
|
-
page_metadata: Dict[
|
|
75
|
+
page_path: Path,
|
|
76
|
+
page_metadata: Dict[Path, ConfluencePageMetadata],
|
|
73
77
|
) -> None:
|
|
74
|
-
base_path =
|
|
78
|
+
base_path = page_path.parent
|
|
75
79
|
|
|
76
80
|
LOGGER.info(f"Synchronizing page: {page_path}")
|
|
77
81
|
document = ConfluenceDocument(page_path, self.options, page_metadata)
|
|
@@ -83,7 +87,7 @@ class Application:
|
|
|
83
87
|
self._update_document(document, base_path)
|
|
84
88
|
|
|
85
89
|
def _get_or_create_page(
|
|
86
|
-
self, absolute_path:
|
|
90
|
+
self, absolute_path: Path, title: Optional[str] = None
|
|
87
91
|
) -> ConfluencePageMetadata:
|
|
88
92
|
"""
|
|
89
93
|
Creates a new Confluence page if no page is linked in the Markdown document.
|
|
@@ -106,7 +110,7 @@ class Application:
|
|
|
106
110
|
|
|
107
111
|
# use file name without extension if no title is supplied
|
|
108
112
|
if title is None:
|
|
109
|
-
title
|
|
113
|
+
title = absolute_path.stem
|
|
110
114
|
|
|
111
115
|
confluence_page = self.api.get_or_create_page(
|
|
112
116
|
title, self.options.root_page_id
|
|
@@ -126,10 +130,10 @@ class Application:
|
|
|
126
130
|
title=confluence_page.title or "",
|
|
127
131
|
)
|
|
128
132
|
|
|
129
|
-
def _update_document(self, document: ConfluenceDocument, base_path:
|
|
133
|
+
def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
|
|
130
134
|
for image in document.images:
|
|
131
135
|
self.api.upload_attachment(
|
|
132
|
-
document.id.page_id,
|
|
136
|
+
document.id.page_id, base_path / image, attachment_name(image), ""
|
|
133
137
|
)
|
|
134
138
|
|
|
135
139
|
content = document.xhtml()
|
|
@@ -138,7 +142,7 @@ class Application:
|
|
|
138
142
|
|
|
139
143
|
def _update_markdown(
|
|
140
144
|
self,
|
|
141
|
-
path:
|
|
145
|
+
path: Path,
|
|
142
146
|
document: str,
|
|
143
147
|
page_id: str,
|
|
144
148
|
space_key: Optional[str],
|
|
@@ -207,11 +207,6 @@ class NodeVisitor:
|
|
|
207
207
|
pass
|
|
208
208
|
|
|
209
209
|
|
|
210
|
-
def _change_ext(path: str, target_ext: str) -> str:
|
|
211
|
-
root, source_ext = os.path.splitext(path)
|
|
212
|
-
return f"{root}{target_ext}"
|
|
213
|
-
|
|
214
|
-
|
|
215
210
|
@dataclass
|
|
216
211
|
class ConfluenceConverterOptions:
|
|
217
212
|
"""
|
|
@@ -228,22 +223,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
228
223
|
"Transforms a plain HTML tree into the Confluence storage format."
|
|
229
224
|
|
|
230
225
|
options: ConfluenceConverterOptions
|
|
231
|
-
path:
|
|
232
|
-
base_path:
|
|
226
|
+
path: pathlib.Path
|
|
227
|
+
base_path: pathlib.Path
|
|
233
228
|
links: List[str]
|
|
234
229
|
images: List[str]
|
|
235
|
-
page_metadata: Dict[
|
|
230
|
+
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
|
|
236
231
|
|
|
237
232
|
def __init__(
|
|
238
233
|
self,
|
|
239
234
|
options: ConfluenceConverterOptions,
|
|
240
|
-
path:
|
|
241
|
-
page_metadata: Dict[
|
|
235
|
+
path: pathlib.Path,
|
|
236
|
+
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
|
|
242
237
|
) -> None:
|
|
243
238
|
super().__init__()
|
|
244
239
|
self.options = options
|
|
245
240
|
self.path = path
|
|
246
|
-
self.base_path =
|
|
241
|
+
self.base_path = path.parent
|
|
247
242
|
self.links = []
|
|
248
243
|
self.images = []
|
|
249
244
|
self.page_metadata = page_metadata
|
|
@@ -270,9 +265,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
270
265
|
# convert the relative URL to absolute URL based on the base path value, then look up
|
|
271
266
|
# the absolute path in the page metadata dictionary to discover the relative path
|
|
272
267
|
# within Confluence that should be used
|
|
273
|
-
absolute_path =
|
|
274
|
-
if not absolute_path.startswith(self.base_path):
|
|
275
|
-
msg = f"relative URL points to outside base path: {
|
|
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}"
|
|
276
271
|
if self.options.ignore_invalid_url:
|
|
277
272
|
LOGGER.warning(msg)
|
|
278
273
|
anchor.attrib.pop("href")
|
|
@@ -314,10 +309,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
314
309
|
path: str = image.attrib["src"]
|
|
315
310
|
|
|
316
311
|
# prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
|
|
317
|
-
if path and is_relative_url(path)
|
|
318
|
-
|
|
319
|
-
if
|
|
320
|
-
|
|
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"))
|
|
321
319
|
|
|
322
320
|
self.images.append(path)
|
|
323
321
|
caption = image.attrib["alt"]
|
|
@@ -327,7 +325,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
327
325
|
ET.QName(namespaces["ac"], "align"): "center",
|
|
328
326
|
ET.QName(namespaces["ac"], "layout"): "center",
|
|
329
327
|
},
|
|
330
|
-
RI(
|
|
328
|
+
RI(
|
|
329
|
+
"attachment",
|
|
330
|
+
{ET.QName(namespaces["ri"], "filename"): attachment_name(path)},
|
|
331
|
+
),
|
|
331
332
|
AC("caption", HTML.p(caption)),
|
|
332
333
|
)
|
|
333
334
|
|
|
@@ -391,6 +392,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
391
392
|
if class_name is None:
|
|
392
393
|
raise DocumentError(f"unsupported admonition label: {class_list}")
|
|
393
394
|
|
|
395
|
+
for e in elem:
|
|
396
|
+
self.visit(e)
|
|
397
|
+
|
|
394
398
|
# <p class="admonition-title">Note</p>
|
|
395
399
|
if "admonition-title" in elem[0].attrib.get("class", "").split(" "):
|
|
396
400
|
content = [
|
|
@@ -530,27 +534,33 @@ class ConfluenceDocument:
|
|
|
530
534
|
|
|
531
535
|
def __init__(
|
|
532
536
|
self,
|
|
533
|
-
path:
|
|
537
|
+
path: pathlib.Path,
|
|
534
538
|
options: ConfluenceDocumentOptions,
|
|
535
|
-
page_metadata: Dict[
|
|
539
|
+
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
|
|
536
540
|
) -> None:
|
|
537
541
|
self.options = options
|
|
538
|
-
path =
|
|
542
|
+
path = path.absolute()
|
|
539
543
|
|
|
540
544
|
with open(path, "r") as f:
|
|
541
|
-
|
|
545
|
+
text = f.read()
|
|
542
546
|
|
|
543
547
|
# extract Confluence page ID
|
|
544
|
-
qualified_id,
|
|
548
|
+
qualified_id, text = extract_qualified_id(text)
|
|
545
549
|
if qualified_id is None:
|
|
546
550
|
raise ValueError("missing Confluence page ID")
|
|
547
551
|
self.id = qualified_id
|
|
548
552
|
|
|
549
553
|
# extract 'generated-by' tag text
|
|
550
|
-
generated_by_tag,
|
|
551
|
-
r"<!--\s+generated-by:\s*(.*)\s+-->",
|
|
554
|
+
generated_by_tag, text = extract_value(
|
|
555
|
+
r"<!--\s+generated-by:\s*(.*)\s+-->", text
|
|
552
556
|
)
|
|
553
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
|
+
|
|
554
564
|
# parse Markdown document
|
|
555
565
|
if self.options.generated_by is not None:
|
|
556
566
|
generated_by = self.options.generated_by
|
|
@@ -582,6 +592,18 @@ class ConfluenceDocument:
|
|
|
582
592
|
return _content_to_string(self.root)
|
|
583
593
|
|
|
584
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
|
+
|
|
585
607
|
def sanitize_confluence(html: str) -> str:
|
|
586
608
|
"Generates a sanitized version of a Confluence storage format XHTML document with no volatile attributes."
|
|
587
609
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import os
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
from typing import Dict
|
|
4
5
|
|
|
5
6
|
from .converter import (
|
|
@@ -23,35 +24,37 @@ class Processor:
|
|
|
23
24
|
self.options = options
|
|
24
25
|
self.properties = properties
|
|
25
26
|
|
|
26
|
-
def process(self, path:
|
|
27
|
+
def process(self, path: Path) -> None:
|
|
27
28
|
"Processes a single Markdown file or a directory of Markdown files."
|
|
28
29
|
|
|
29
|
-
if
|
|
30
|
+
if path.is_dir():
|
|
30
31
|
self.process_directory(path)
|
|
31
|
-
elif
|
|
32
|
+
elif path.is_file():
|
|
32
33
|
self.process_page(path, {})
|
|
33
34
|
else:
|
|
34
35
|
raise ValueError(f"expected: valid file or directory path; got: {path}")
|
|
35
36
|
|
|
36
|
-
def process_directory(self,
|
|
37
|
+
def process_directory(self, local_dir: Path) -> None:
|
|
37
38
|
"Recursively scans a directory hierarchy for Markdown files."
|
|
38
39
|
|
|
39
|
-
page_metadata: Dict[
|
|
40
|
-
LOGGER.info(f"Synchronizing directory: {
|
|
40
|
+
page_metadata: Dict[Path, ConfluencePageMetadata] = {}
|
|
41
|
+
LOGGER.info(f"Synchronizing directory: {local_dir}")
|
|
41
42
|
|
|
42
43
|
# Step 1: build index of all page metadata
|
|
43
|
-
|
|
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):
|
|
44
47
|
for file_name in files:
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
if file_extension.lower() != ".md":
|
|
48
|
-
continue
|
|
48
|
+
# Reconstitute Path object back
|
|
49
|
+
docfile = (Path(root) / file_name).absolute()
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
# Skip non-markdown files
|
|
52
|
+
if docfile.suffix.lower() != ".md":
|
|
53
|
+
continue
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
metadata = self._get_page(docfile)
|
|
56
|
+
LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
|
|
57
|
+
page_metadata[docfile] = metadata
|
|
55
58
|
|
|
56
59
|
LOGGER.info(f"indexed {len(page_metadata)} pages")
|
|
57
60
|
|
|
@@ -60,17 +63,16 @@ class Processor:
|
|
|
60
63
|
self.process_page(page_path, page_metadata)
|
|
61
64
|
|
|
62
65
|
def process_page(
|
|
63
|
-
self, path:
|
|
66
|
+
self, path: Path, page_metadata: Dict[Path, ConfluencePageMetadata]
|
|
64
67
|
) -> None:
|
|
65
68
|
"Processes a single Markdown file."
|
|
66
69
|
|
|
67
70
|
document = ConfluenceDocument(path, self.options, page_metadata)
|
|
68
71
|
content = document.xhtml()
|
|
69
|
-
|
|
70
|
-
with open(f"{output_path}.csf", "w") as f:
|
|
72
|
+
with open(path.with_suffix(".csf"), "w") as f:
|
|
71
73
|
f.write(content)
|
|
72
74
|
|
|
73
|
-
def _get_page(self, absolute_path:
|
|
75
|
+
def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
|
|
74
76
|
"Extracts metadata from a Markdown file."
|
|
75
77
|
|
|
76
78
|
with open(absolute_path, "r") as f:
|
|
@@ -28,13 +28,13 @@ include_package_data = True
|
|
|
28
28
|
packages = find:
|
|
29
29
|
python_requires = >=3.8
|
|
30
30
|
install_requires =
|
|
31
|
-
lxml >=
|
|
32
|
-
types-lxml >=
|
|
33
|
-
markdown >= 3.
|
|
34
|
-
types-markdown >= 3.
|
|
35
|
-
pymdown-extensions >= 10.
|
|
36
|
-
requests >= 2.
|
|
37
|
-
types-requests >= 2.
|
|
31
|
+
lxml >= 5.2
|
|
32
|
+
types-lxml >= 2024.4.14
|
|
33
|
+
markdown >= 3.6
|
|
34
|
+
types-markdown >= 3.6
|
|
35
|
+
pymdown-extensions >= 10.8
|
|
36
|
+
requests >= 2.32
|
|
37
|
+
types-requests >= 2.32
|
|
38
38
|
|
|
39
39
|
[options.packages.find]
|
|
40
40
|
exclude =
|
|
@@ -45,6 +45,10 @@ md2conf =
|
|
|
45
45
|
entities.dtd
|
|
46
46
|
py.typed
|
|
47
47
|
|
|
48
|
+
[options.entry_points]
|
|
49
|
+
console_scripts =
|
|
50
|
+
md2conf = md2conf.__main__:main
|
|
51
|
+
|
|
48
52
|
[flake8]
|
|
49
53
|
extend_ignore = DAR101,DAR201,DAR301,DAR401
|
|
50
54
|
max_line_length = 140
|
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import os.path
|
|
4
4
|
import shutil
|
|
5
5
|
import unittest
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
from md2conf.api import ConfluenceAPI, ConfluenceAttachment, ConfluencePage
|
|
8
9
|
from md2conf.application import Application
|
|
@@ -13,6 +14,10 @@ from md2conf.converter import (
|
|
|
13
14
|
)
|
|
14
15
|
from md2conf.properties import ConfluenceProperties
|
|
15
16
|
|
|
17
|
+
TEST_PAGE_TITLE = "Publish to Confluence"
|
|
18
|
+
TEST_SPACE = "DAP"
|
|
19
|
+
TEST_PAGE_ID = "85668266616"
|
|
20
|
+
|
|
16
21
|
logging.basicConfig(
|
|
17
22
|
level=logging.INFO,
|
|
18
23
|
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
@@ -20,10 +25,14 @@ logging.basicConfig(
|
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
class TestAPI(unittest.TestCase):
|
|
23
|
-
out_dir:
|
|
28
|
+
out_dir: Path
|
|
24
29
|
|
|
25
30
|
def setUp(self) -> None:
|
|
26
|
-
|
|
31
|
+
test_dir = Path(__file__).parent
|
|
32
|
+
parent_dir = test_dir.parent
|
|
33
|
+
|
|
34
|
+
self.out_dir = test_dir / "tests" / "output"
|
|
35
|
+
self.sample_dir = parent_dir / "sample"
|
|
27
36
|
os.makedirs(self.out_dir, exist_ok=True)
|
|
28
37
|
|
|
29
38
|
def tearDown(self) -> None:
|
|
@@ -31,7 +40,7 @@ class TestAPI(unittest.TestCase):
|
|
|
31
40
|
|
|
32
41
|
def test_markdown(self) -> None:
|
|
33
42
|
document = ConfluenceDocument(
|
|
34
|
-
|
|
43
|
+
self.sample_dir / "example.md",
|
|
35
44
|
ConfluenceDocumentOptions(ignore_invalid_url=True),
|
|
36
45
|
{},
|
|
37
46
|
)
|
|
@@ -41,67 +50,68 @@ class TestAPI(unittest.TestCase):
|
|
|
41
50
|
["figure/interoperability.png", "figure/interoperability.png"],
|
|
42
51
|
)
|
|
43
52
|
|
|
44
|
-
with open(
|
|
53
|
+
with open(self.out_dir / "document.html", "w") as f:
|
|
45
54
|
f.write(document.xhtml())
|
|
46
55
|
|
|
47
56
|
def test_find_page_by_title(self) -> None:
|
|
48
57
|
with ConfluenceAPI() as api:
|
|
49
|
-
id = api.get_page_id_by_title(
|
|
50
|
-
self.assertEqual(id, "
|
|
58
|
+
id = api.get_page_id_by_title(TEST_PAGE_TITLE)
|
|
59
|
+
self.assertEqual(id, "%s" % TEST_PAGE_ID)
|
|
51
60
|
|
|
52
61
|
def test_switch_space(self) -> None:
|
|
53
62
|
with ConfluenceAPI(ConfluenceProperties(space_key="PLAT")) as api:
|
|
54
|
-
with api.switch_space(
|
|
55
|
-
id = api.get_page_id_by_title(
|
|
56
|
-
self.assertEqual(id,
|
|
63
|
+
with api.switch_space(TEST_SPACE):
|
|
64
|
+
id = api.get_page_id_by_title(TEST_PAGE_TITLE)
|
|
65
|
+
self.assertEqual(id, TEST_PAGE_ID)
|
|
57
66
|
|
|
58
67
|
def test_get_page(self) -> None:
|
|
59
68
|
with ConfluenceAPI() as api:
|
|
60
|
-
page = api.get_page(
|
|
69
|
+
page = api.get_page(TEST_PAGE_ID)
|
|
61
70
|
self.assertIsInstance(page, ConfluencePage)
|
|
62
71
|
|
|
63
|
-
with open(
|
|
72
|
+
with open(self.out_dir / "page.html", "w") as f:
|
|
64
73
|
f.write(sanitize_confluence(page.content))
|
|
65
74
|
|
|
66
75
|
def test_get_attachment(self) -> None:
|
|
67
76
|
with ConfluenceAPI() as api:
|
|
68
77
|
data = api.get_attachment_by_name(
|
|
69
|
-
|
|
78
|
+
TEST_PAGE_ID, "figure_interoperability.png"
|
|
70
79
|
)
|
|
71
80
|
self.assertIsInstance(data, ConfluenceAttachment)
|
|
72
81
|
|
|
73
82
|
def test_upload_attachment(self) -> None:
|
|
74
83
|
with ConfluenceAPI() as api:
|
|
75
84
|
api.upload_attachment(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"
|
|
85
|
+
TEST_PAGE_ID,
|
|
86
|
+
self.sample_dir / "figure" / "interoperability.png",
|
|
87
|
+
"figure_interoperability.png",
|
|
79
88
|
"A sample figure",
|
|
89
|
+
force=True,
|
|
80
90
|
)
|
|
81
91
|
|
|
82
92
|
def test_synchronize(self) -> None:
|
|
83
93
|
with ConfluenceAPI() as api:
|
|
84
94
|
Application(
|
|
85
95
|
api, ConfluenceDocumentOptions(ignore_invalid_url=True)
|
|
86
|
-
).synchronize(
|
|
96
|
+
).synchronize(self.sample_dir / "example.md")
|
|
87
97
|
|
|
88
98
|
def test_synchronize_page(self) -> None:
|
|
89
99
|
with ConfluenceAPI() as api:
|
|
90
100
|
Application(
|
|
91
101
|
api, ConfluenceDocumentOptions(ignore_invalid_url=True)
|
|
92
|
-
).synchronize_page(
|
|
102
|
+
).synchronize_page(self.sample_dir / "example.md")
|
|
93
103
|
|
|
94
104
|
def test_synchronize_directory(self) -> None:
|
|
95
105
|
with ConfluenceAPI() as api:
|
|
96
106
|
Application(
|
|
97
107
|
api, ConfluenceDocumentOptions(ignore_invalid_url=True)
|
|
98
|
-
).synchronize_directory(
|
|
108
|
+
).synchronize_directory(self.sample_dir)
|
|
99
109
|
|
|
100
110
|
def test_synchronize_create(self) -> None:
|
|
101
|
-
|
|
102
|
-
os.makedirs(
|
|
111
|
+
source_dir = self.out_dir / "markdown"
|
|
112
|
+
os.makedirs(source_dir, exist_ok=True)
|
|
103
113
|
|
|
104
|
-
child =
|
|
114
|
+
child = source_dir / "child.md"
|
|
105
115
|
with open(child, "w") as f:
|
|
106
116
|
f.write(
|
|
107
117
|
"This is a document without an explicitly linked Confluence document.\n"
|
|
@@ -113,13 +123,13 @@ class TestAPI(unittest.TestCase):
|
|
|
113
123
|
ConfluenceDocumentOptions(
|
|
114
124
|
ignore_invalid_url=True, root_page_id="86090481730"
|
|
115
125
|
),
|
|
116
|
-
).synchronize_directory(
|
|
126
|
+
).synchronize_directory(source_dir)
|
|
117
127
|
|
|
118
128
|
with open(child, "r") as f:
|
|
119
129
|
self.assertEqual(
|
|
120
130
|
f.read(),
|
|
121
131
|
"<!-- confluence-page-id: 86269493445 -->\n"
|
|
122
|
-
"<!-- confluence-space-key:
|
|
132
|
+
f"<!-- confluence-space-key: {TEST_SPACE} -->\n"
|
|
123
133
|
"This is a document without an explicitly linked Confluence document.\n",
|
|
124
134
|
)
|
|
125
135
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import os.path
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from md2conf.converter import (
|
|
10
|
+
ConfluenceDocument,
|
|
11
|
+
ConfluenceDocumentOptions,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.INFO,
|
|
16
|
+
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestConversion(unittest.TestCase):
|
|
21
|
+
out_dir: Path
|
|
22
|
+
|
|
23
|
+
def setUp(self) -> None:
|
|
24
|
+
self.maxDiff = 1024
|
|
25
|
+
|
|
26
|
+
test_dir = Path(__file__).parent
|
|
27
|
+
parent_dir = test_dir.parent
|
|
28
|
+
|
|
29
|
+
self.out_dir = test_dir / "output"
|
|
30
|
+
self.sample_dir = parent_dir / "sample"
|
|
31
|
+
os.makedirs(self.out_dir, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
def tearDown(self) -> None:
|
|
34
|
+
shutil.rmtree(self.out_dir)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def make_canonical(content: str) -> str:
|
|
38
|
+
uuid_pattern = re.compile(r'\b[0-9a-fA-F-]{36}\b')
|
|
39
|
+
content = re.sub(uuid_pattern, 'UUID', content)
|
|
40
|
+
content = content.strip()
|
|
41
|
+
return content
|
|
42
|
+
|
|
43
|
+
def test_markdown(self) -> None:
|
|
44
|
+
actual = ConfluenceDocument(
|
|
45
|
+
self.sample_dir / "example.md",
|
|
46
|
+
ConfluenceDocumentOptions(ignore_invalid_url=True),
|
|
47
|
+
{},
|
|
48
|
+
).xhtml()
|
|
49
|
+
actual = self.make_canonical(actual)
|
|
50
|
+
|
|
51
|
+
with open(self.sample_dir / "expected" / "example.xml", "r") as f:
|
|
52
|
+
expected = f.read().strip()
|
|
53
|
+
|
|
54
|
+
self.assertEqual(actual, expected)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
unittest.main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import shutil
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from md2conf.converter import ConfluenceDocumentOptions
|
|
7
|
+
from md2conf.processor import Processor
|
|
8
|
+
from md2conf.properties import ConfluenceProperties
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestProcessor(unittest.TestCase):
|
|
17
|
+
out_dir: Path
|
|
18
|
+
|
|
19
|
+
def setUp(self) -> None:
|
|
20
|
+
self.maxDiff = 1024
|
|
21
|
+
|
|
22
|
+
test_dir = Path(__file__).parent
|
|
23
|
+
parent_dir = test_dir.parent
|
|
24
|
+
|
|
25
|
+
self.out_dir = test_dir / "output"
|
|
26
|
+
self.sample_dir = parent_dir / "sample"
|
|
27
|
+
self.out_dir.mkdir(exist_ok=True, parents=True)
|
|
28
|
+
|
|
29
|
+
def tearDown(self) -> None:
|
|
30
|
+
shutil.rmtree(self.out_dir)
|
|
31
|
+
|
|
32
|
+
def test_process_document(self) -> None:
|
|
33
|
+
options = ConfluenceDocumentOptions(
|
|
34
|
+
ignore_invalid_url=False,
|
|
35
|
+
generated_by="Test Case",
|
|
36
|
+
root_page_id="None",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
properties = ConfluenceProperties(
|
|
40
|
+
"example.com", "/wiki/", "bob@example.com", "API_KEY", "SPACE_KEY"
|
|
41
|
+
)
|
|
42
|
+
Processor(options, properties).process(self.sample_dir / "code.md")
|
|
43
|
+
|
|
44
|
+
self.assertTrue((self.sample_dir / "example.csf").exists())
|
|
45
|
+
|
|
46
|
+
def test_process_directory(self) -> None:
|
|
47
|
+
options = ConfluenceDocumentOptions(
|
|
48
|
+
ignore_invalid_url=True,
|
|
49
|
+
generated_by="The Author",
|
|
50
|
+
root_page_id="ROOT_PAGE_ID",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
properties = ConfluenceProperties(
|
|
54
|
+
"example.com", "/wiki/", "bob@example.com", "API_KEY", "SPACE_KEY"
|
|
55
|
+
)
|
|
56
|
+
Processor(options, properties).process(self.sample_dir)
|
|
57
|
+
|
|
58
|
+
self.assertTrue((self.sample_dir / "example.csf").exists())
|
|
59
|
+
self.assertTrue((self.sample_dir / "sibling.csf").exists())
|
|
60
|
+
self.assertTrue((self.sample_dir / "code.csf").exists())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
unittest.main()
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
import logging
|
|
3
|
-
import os.path
|
|
4
|
-
import sys
|
|
5
|
-
import typing
|
|
6
|
-
from typing import Optional
|
|
7
|
-
|
|
8
|
-
import requests
|
|
9
|
-
|
|
10
|
-
from .api import ConfluenceAPI
|
|
11
|
-
from .application import Application
|
|
12
|
-
from .converter import ConfluenceDocumentOptions
|
|
13
|
-
from .processor import Processor
|
|
14
|
-
from .properties import ConfluenceProperties
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class Arguments(argparse.Namespace):
|
|
18
|
-
mdpath: str
|
|
19
|
-
domain: str
|
|
20
|
-
path: str
|
|
21
|
-
username: str
|
|
22
|
-
apikey: str
|
|
23
|
-
space: str
|
|
24
|
-
loglevel: str
|
|
25
|
-
ignore_invalid_url: bool
|
|
26
|
-
generated_by: Optional[str]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
parser = argparse.ArgumentParser()
|
|
30
|
-
parser.prog = os.path.basename(os.path.dirname(__file__))
|
|
31
|
-
parser.add_argument(
|
|
32
|
-
"mdpath", help="Path to Markdown file or directory to convert and publish."
|
|
33
|
-
)
|
|
34
|
-
parser.add_argument("-d", "--domain", help="Confluence organization domain.")
|
|
35
|
-
parser.add_argument("-p", "--path", help="Base path for Confluece wiki.")
|
|
36
|
-
parser.add_argument("-u", "--username", help="Confluence user name.")
|
|
37
|
-
parser.add_argument(
|
|
38
|
-
"-a",
|
|
39
|
-
"--apikey",
|
|
40
|
-
help="Confluence API key. Refer to documentation how to obtain one.",
|
|
41
|
-
)
|
|
42
|
-
parser.add_argument(
|
|
43
|
-
"-s",
|
|
44
|
-
"--space",
|
|
45
|
-
help="Confluence space key for pages to be published. If omitted, will default to user space.",
|
|
46
|
-
)
|
|
47
|
-
parser.add_argument(
|
|
48
|
-
"-l",
|
|
49
|
-
"--loglevel",
|
|
50
|
-
choices=[
|
|
51
|
-
typing.cast(str, logging.getLevelName(level)).lower()
|
|
52
|
-
for level in (
|
|
53
|
-
logging.DEBUG,
|
|
54
|
-
logging.INFO,
|
|
55
|
-
logging.WARN,
|
|
56
|
-
logging.ERROR,
|
|
57
|
-
logging.CRITICAL,
|
|
58
|
-
)
|
|
59
|
-
],
|
|
60
|
-
default=logging.getLevelName(logging.INFO),
|
|
61
|
-
help="Use this option to set the log verbosity.",
|
|
62
|
-
)
|
|
63
|
-
parser.add_argument(
|
|
64
|
-
"-r",
|
|
65
|
-
dest="root_page",
|
|
66
|
-
help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
|
|
67
|
-
)
|
|
68
|
-
parser.add_argument(
|
|
69
|
-
"--generated-by",
|
|
70
|
-
default="This page has been generated with a tool.",
|
|
71
|
-
help="Add prompt to pages (default: 'This page has been generated with a tool.').",
|
|
72
|
-
)
|
|
73
|
-
parser.add_argument(
|
|
74
|
-
"--no-generated-by",
|
|
75
|
-
dest="generated_by",
|
|
76
|
-
action="store_const",
|
|
77
|
-
const=None,
|
|
78
|
-
help="Do not add 'generated by a tool' prompt to pages.",
|
|
79
|
-
)
|
|
80
|
-
parser.add_argument(
|
|
81
|
-
"--ignore-invalid-url",
|
|
82
|
-
action="store_true",
|
|
83
|
-
default=False,
|
|
84
|
-
help="Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.",
|
|
85
|
-
)
|
|
86
|
-
parser.add_argument(
|
|
87
|
-
"--local",
|
|
88
|
-
action="store_true",
|
|
89
|
-
default=False,
|
|
90
|
-
help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
args = Arguments()
|
|
94
|
-
parser.parse_args(namespace=args)
|
|
95
|
-
|
|
96
|
-
logging.basicConfig(
|
|
97
|
-
level=getattr(logging, args.loglevel.upper(), logging.INFO),
|
|
98
|
-
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
options = ConfluenceDocumentOptions(
|
|
103
|
-
ignore_invalid_url=args.ignore_invalid_url,
|
|
104
|
-
generated_by=args.generated_by,
|
|
105
|
-
root_page_id=args.root_page,
|
|
106
|
-
)
|
|
107
|
-
properties = ConfluenceProperties(
|
|
108
|
-
args.domain, args.path, args.username, args.apikey, args.space
|
|
109
|
-
)
|
|
110
|
-
if args.local:
|
|
111
|
-
Processor(options, properties).process(args.mdpath)
|
|
112
|
-
else:
|
|
113
|
-
try:
|
|
114
|
-
with ConfluenceAPI(properties) as api:
|
|
115
|
-
Application(
|
|
116
|
-
api,
|
|
117
|
-
options,
|
|
118
|
-
).synchronize(args.mdpath)
|
|
119
|
-
except requests.exceptions.HTTPError as err:
|
|
120
|
-
logging.error(err)
|
|
121
|
-
|
|
122
|
-
# print details for a response with JSON body
|
|
123
|
-
if err.response is not None:
|
|
124
|
-
try:
|
|
125
|
-
logging.error(err.response.json())
|
|
126
|
-
except requests.exceptions.JSONDecodeError:
|
|
127
|
-
pass
|
|
128
|
-
|
|
129
|
-
sys.exit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|