markdown-to-confluence 0.2.4__tar.gz → 0.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/PKG-INFO +23 -15
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/README.md +18 -10
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/markdown_to_confluence.egg-info/PKG-INFO +23 -15
- markdown_to_confluence-0.2.6/markdown_to_confluence.egg-info/requires.txt +9 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/__init__.py +1 -1
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/__main__.py +11 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/api.py +37 -11
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/application.py +53 -20
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/converter.py +50 -27
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/emoji.py +8 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/matcher.py +8 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/mermaid.py +13 -1
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/processor.py +44 -13
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/properties.py +8 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/util.py +8 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/setup.cfg +4 -4
- markdown_to_confluence-0.2.6/setup.py +12 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/tests/test_conversion.py +27 -3
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/tests/test_matcher.py +9 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/tests/test_mermaid.py +8 -1
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/tests/test_processor.py +8 -2
- markdown_to_confluence-0.2.4/markdown_to_confluence.egg-info/requires.txt +0 -9
- markdown_to_confluence-0.2.4/setup.py +0 -4
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/LICENSE +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/markdown_to_confluence.egg-info/SOURCES.txt +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/markdown_to_confluence.egg-info/dependency_links.txt +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/markdown_to_confluence.egg-info/entry_points.txt +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/markdown_to_confluence.egg-info/top_level.txt +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/markdown_to_confluence.egg-info/zip-safe +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/entities.dtd +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/puppeteer-config.json +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/md2conf/py.typed +0 -0
- {markdown_to_confluence-0.2.4 → markdown_to_confluence-0.2.6}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
@@ -22,10 +22,10 @@ Requires-Python: >=3.8
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: lxml>=5.3
|
|
25
|
-
Requires-Dist: types-lxml>=2024.8
|
|
26
|
-
Requires-Dist: markdown>=3.
|
|
27
|
-
Requires-Dist: types-markdown>=3.
|
|
28
|
-
Requires-Dist: pymdown-extensions>=10.
|
|
25
|
+
Requires-Dist: types-lxml>=2024.11.8
|
|
26
|
+
Requires-Dist: markdown>=3.7
|
|
27
|
+
Requires-Dist: types-markdown>=3.7
|
|
28
|
+
Requires-Dist: pymdown-extensions>=10.12
|
|
29
29
|
Requires-Dist: pyyaml>=6.0
|
|
30
30
|
Requires-Dist: types-PyYAML>=6.0
|
|
31
31
|
Requires-Dist: requests>=2.32
|
|
@@ -75,11 +75,11 @@ npm install -g @mermaid-js/mermaid-cli
|
|
|
75
75
|
|
|
76
76
|
In order to get started, you will need
|
|
77
77
|
|
|
78
|
-
* your organization domain name (e.g. `
|
|
78
|
+
* your organization domain name (e.g. `example.atlassian.net`),
|
|
79
79
|
* base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
|
|
80
80
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
|
|
81
81
|
* a Confluence API token (a string of alphanumeric characters), and
|
|
82
|
-
* the space key in Confluence (e.g. `
|
|
82
|
+
* the space key in Confluence (e.g. `SPACE`) you are publishing content to.
|
|
83
83
|
|
|
84
84
|
### Obtaining an API token
|
|
85
85
|
|
|
@@ -93,11 +93,11 @@ In order to get started, you will need
|
|
|
93
93
|
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):
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
|
-
export CONFLUENCE_DOMAIN='
|
|
96
|
+
export CONFLUENCE_DOMAIN='example.atlassian.net'
|
|
97
97
|
export CONFLUENCE_PATH='/wiki/'
|
|
98
98
|
export CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
|
|
99
99
|
export CONFLUENCE_API_KEY='0123456789abcdef'
|
|
100
|
-
export CONFLUENCE_SPACE_KEY='
|
|
100
|
+
export CONFLUENCE_SPACE_KEY='SPACE'
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
On Windows, these can be set via system properties.
|
|
@@ -129,7 +129,7 @@ The above tells the tool to synchronize the Markdown file with the given Conflue
|
|
|
129
129
|
If you work in an environment where there are multiple Confluence spaces, and some Markdown pages may go into one space, whereas other pages may go into another, you can set the target space on a per-document basis:
|
|
130
130
|
|
|
131
131
|
```markdown
|
|
132
|
-
<!-- confluence-space-key:
|
|
132
|
+
<!-- confluence-space-key: SPACE -->
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
This overrides the default space set via command-line arguments or environment variables.
|
|
@@ -146,9 +146,17 @@ Provide generated-by prompt text in the Markdown file with a tag:
|
|
|
146
146
|
|
|
147
147
|
Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes precedence.
|
|
148
148
|
|
|
149
|
+
### Publishing a single page
|
|
150
|
+
|
|
151
|
+
*md2conf* has two modes of operation: *single-page mode* and *directory mode*.
|
|
152
|
+
|
|
153
|
+
In single-page mode, you specify a single Markdown file as the source, which can contain absolute links to external locations (e.g. `https://example.com`) but not relative links to other pages (e.g. `local.md`). In other words, the page must be stand-alone.
|
|
154
|
+
|
|
149
155
|
### Publishing a directory
|
|
150
156
|
|
|
151
|
-
*md2conf* allows you to convert and publish a directory of Markdown files rather than a single Markdown file if you pass a directory as
|
|
157
|
+
*md2conf* allows you to convert and publish a directory of Markdown files rather than a single Markdown file in *directory mode* if you pass a directory as the source. This will traverse the specified directory recursively, and synchronize each Markdown file.
|
|
158
|
+
|
|
159
|
+
First, *md2conf* builds an index of pages in the directory hierarchy. The index maps each Markdown file path to a Confluence page ID. Whenever a relative link is encountered in a Markdown file, the relative link is replaced with a Confluence URL to the referenced page with the help of the index. All relative links must point to Markdown files that are located in the directory hierarchy.
|
|
152
160
|
|
|
153
161
|
If a Markdown file doesn't yet pair up with a Confluence page, *md2conf* creates a new page and assigns a parent. Parent-child relationships are reflected in the navigation panel in Confluence. You can set a root page ID with the command-line option `-r`, which constitutes the topmost parent. (This could correspond to the landing page of your Confluence space. The Confluence page ID is always revealed when you edit a page.) Whenever a directory contains the file `index.md` or `README.md`, this page becomes the future parent page, and all Markdown files in this directory (and possibly nested directories) become its child pages (unless they already have a page ID). However, if an `index.md` or `README.md` file is subsequently found in one of the nested directories, it becomes the parent page of that directory, and any of its subdirectories.
|
|
154
162
|
|
|
@@ -216,7 +224,7 @@ You can run the Docker container via `docker run` or via `Dockerfile`. Either ca
|
|
|
216
224
|
With `docker run`, you can pass Confluence domain, user, API and space key directly to `docker run`:
|
|
217
225
|
|
|
218
226
|
```sh
|
|
219
|
-
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d
|
|
227
|
+
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d example.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s SPACE ./
|
|
220
228
|
```
|
|
221
229
|
|
|
222
230
|
Alternatively, you can use a separate file `.env` to pass these parameters as environment variables:
|
|
@@ -234,11 +242,11 @@ With the `Dockerfile` approach, you can extend the base image:
|
|
|
234
242
|
```Dockerfile
|
|
235
243
|
FROM leventehunyadi/md2conf:latest
|
|
236
244
|
|
|
237
|
-
ENV CONFLUENCE_DOMAIN='
|
|
245
|
+
ENV CONFLUENCE_DOMAIN='example.atlassian.net'
|
|
238
246
|
ENV CONFLUENCE_PATH='/wiki/'
|
|
239
247
|
ENV CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
|
|
240
248
|
ENV CONFLUENCE_API_KEY='0123456789abcdef'
|
|
241
|
-
ENV CONFLUENCE_SPACE_KEY='
|
|
249
|
+
ENV CONFLUENCE_SPACE_KEY='SPACE'
|
|
242
250
|
|
|
243
251
|
CMD ["./"]
|
|
244
252
|
```
|
|
@@ -248,5 +256,5 @@ Alternatively,
|
|
|
248
256
|
```Dockerfile
|
|
249
257
|
FROM leventehunyadi/md2conf:latest
|
|
250
258
|
|
|
251
|
-
CMD ["-d", "
|
|
259
|
+
CMD ["-d", "example.atlassian.net", "-u", "levente.hunyadi@instructure.com", "-a", "0123456789abcdef", "-s", "SPACE", "./"]
|
|
252
260
|
```
|
|
@@ -42,11 +42,11 @@ npm install -g @mermaid-js/mermaid-cli
|
|
|
42
42
|
|
|
43
43
|
In order to get started, you will need
|
|
44
44
|
|
|
45
|
-
* your organization domain name (e.g. `
|
|
45
|
+
* your organization domain name (e.g. `example.atlassian.net`),
|
|
46
46
|
* base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
|
|
47
47
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
|
|
48
48
|
* a Confluence API token (a string of alphanumeric characters), and
|
|
49
|
-
* the space key in Confluence (e.g. `
|
|
49
|
+
* the space key in Confluence (e.g. `SPACE`) you are publishing content to.
|
|
50
50
|
|
|
51
51
|
### Obtaining an API token
|
|
52
52
|
|
|
@@ -60,11 +60,11 @@ In order to get started, you will need
|
|
|
60
60
|
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):
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
export CONFLUENCE_DOMAIN='
|
|
63
|
+
export CONFLUENCE_DOMAIN='example.atlassian.net'
|
|
64
64
|
export CONFLUENCE_PATH='/wiki/'
|
|
65
65
|
export CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
|
|
66
66
|
export CONFLUENCE_API_KEY='0123456789abcdef'
|
|
67
|
-
export CONFLUENCE_SPACE_KEY='
|
|
67
|
+
export CONFLUENCE_SPACE_KEY='SPACE'
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
On Windows, these can be set via system properties.
|
|
@@ -96,7 +96,7 @@ The above tells the tool to synchronize the Markdown file with the given Conflue
|
|
|
96
96
|
If you work in an environment where there are multiple Confluence spaces, and some Markdown pages may go into one space, whereas other pages may go into another, you can set the target space on a per-document basis:
|
|
97
97
|
|
|
98
98
|
```markdown
|
|
99
|
-
<!-- confluence-space-key:
|
|
99
|
+
<!-- confluence-space-key: SPACE -->
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
This overrides the default space set via command-line arguments or environment variables.
|
|
@@ -113,9 +113,17 @@ Provide generated-by prompt text in the Markdown file with a tag:
|
|
|
113
113
|
|
|
114
114
|
Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes precedence.
|
|
115
115
|
|
|
116
|
+
### Publishing a single page
|
|
117
|
+
|
|
118
|
+
*md2conf* has two modes of operation: *single-page mode* and *directory mode*.
|
|
119
|
+
|
|
120
|
+
In single-page mode, you specify a single Markdown file as the source, which can contain absolute links to external locations (e.g. `https://example.com`) but not relative links to other pages (e.g. `local.md`). In other words, the page must be stand-alone.
|
|
121
|
+
|
|
116
122
|
### Publishing a directory
|
|
117
123
|
|
|
118
|
-
*md2conf* allows you to convert and publish a directory of Markdown files rather than a single Markdown file if you pass a directory as
|
|
124
|
+
*md2conf* allows you to convert and publish a directory of Markdown files rather than a single Markdown file in *directory mode* if you pass a directory as the source. This will traverse the specified directory recursively, and synchronize each Markdown file.
|
|
125
|
+
|
|
126
|
+
First, *md2conf* builds an index of pages in the directory hierarchy. The index maps each Markdown file path to a Confluence page ID. Whenever a relative link is encountered in a Markdown file, the relative link is replaced with a Confluence URL to the referenced page with the help of the index. All relative links must point to Markdown files that are located in the directory hierarchy.
|
|
119
127
|
|
|
120
128
|
If a Markdown file doesn't yet pair up with a Confluence page, *md2conf* creates a new page and assigns a parent. Parent-child relationships are reflected in the navigation panel in Confluence. You can set a root page ID with the command-line option `-r`, which constitutes the topmost parent. (This could correspond to the landing page of your Confluence space. The Confluence page ID is always revealed when you edit a page.) Whenever a directory contains the file `index.md` or `README.md`, this page becomes the future parent page, and all Markdown files in this directory (and possibly nested directories) become its child pages (unless they already have a page ID). However, if an `index.md` or `README.md` file is subsequently found in one of the nested directories, it becomes the parent page of that directory, and any of its subdirectories.
|
|
121
129
|
|
|
@@ -183,7 +191,7 @@ You can run the Docker container via `docker run` or via `Dockerfile`. Either ca
|
|
|
183
191
|
With `docker run`, you can pass Confluence domain, user, API and space key directly to `docker run`:
|
|
184
192
|
|
|
185
193
|
```sh
|
|
186
|
-
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d
|
|
194
|
+
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d example.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s SPACE ./
|
|
187
195
|
```
|
|
188
196
|
|
|
189
197
|
Alternatively, you can use a separate file `.env` to pass these parameters as environment variables:
|
|
@@ -201,11 +209,11 @@ With the `Dockerfile` approach, you can extend the base image:
|
|
|
201
209
|
```Dockerfile
|
|
202
210
|
FROM leventehunyadi/md2conf:latest
|
|
203
211
|
|
|
204
|
-
ENV CONFLUENCE_DOMAIN='
|
|
212
|
+
ENV CONFLUENCE_DOMAIN='example.atlassian.net'
|
|
205
213
|
ENV CONFLUENCE_PATH='/wiki/'
|
|
206
214
|
ENV CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
|
|
207
215
|
ENV CONFLUENCE_API_KEY='0123456789abcdef'
|
|
208
|
-
ENV CONFLUENCE_SPACE_KEY='
|
|
216
|
+
ENV CONFLUENCE_SPACE_KEY='SPACE'
|
|
209
217
|
|
|
210
218
|
CMD ["./"]
|
|
211
219
|
```
|
|
@@ -215,5 +223,5 @@ Alternatively,
|
|
|
215
223
|
```Dockerfile
|
|
216
224
|
FROM leventehunyadi/md2conf:latest
|
|
217
225
|
|
|
218
|
-
CMD ["-d", "
|
|
226
|
+
CMD ["-d", "example.atlassian.net", "-u", "levente.hunyadi@instructure.com", "-a", "0123456789abcdef", "-s", "SPACE", "./"]
|
|
219
227
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
@@ -22,10 +22,10 @@ Requires-Python: >=3.8
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: lxml>=5.3
|
|
25
|
-
Requires-Dist: types-lxml>=2024.8
|
|
26
|
-
Requires-Dist: markdown>=3.
|
|
27
|
-
Requires-Dist: types-markdown>=3.
|
|
28
|
-
Requires-Dist: pymdown-extensions>=10.
|
|
25
|
+
Requires-Dist: types-lxml>=2024.11.8
|
|
26
|
+
Requires-Dist: markdown>=3.7
|
|
27
|
+
Requires-Dist: types-markdown>=3.7
|
|
28
|
+
Requires-Dist: pymdown-extensions>=10.12
|
|
29
29
|
Requires-Dist: pyyaml>=6.0
|
|
30
30
|
Requires-Dist: types-PyYAML>=6.0
|
|
31
31
|
Requires-Dist: requests>=2.32
|
|
@@ -75,11 +75,11 @@ npm install -g @mermaid-js/mermaid-cli
|
|
|
75
75
|
|
|
76
76
|
In order to get started, you will need
|
|
77
77
|
|
|
78
|
-
* your organization domain name (e.g. `
|
|
78
|
+
* your organization domain name (e.g. `example.atlassian.net`),
|
|
79
79
|
* base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
|
|
80
80
|
* your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
|
|
81
81
|
* a Confluence API token (a string of alphanumeric characters), and
|
|
82
|
-
* the space key in Confluence (e.g. `
|
|
82
|
+
* the space key in Confluence (e.g. `SPACE`) you are publishing content to.
|
|
83
83
|
|
|
84
84
|
### Obtaining an API token
|
|
85
85
|
|
|
@@ -93,11 +93,11 @@ In order to get started, you will need
|
|
|
93
93
|
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):
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
|
-
export CONFLUENCE_DOMAIN='
|
|
96
|
+
export CONFLUENCE_DOMAIN='example.atlassian.net'
|
|
97
97
|
export CONFLUENCE_PATH='/wiki/'
|
|
98
98
|
export CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
|
|
99
99
|
export CONFLUENCE_API_KEY='0123456789abcdef'
|
|
100
|
-
export CONFLUENCE_SPACE_KEY='
|
|
100
|
+
export CONFLUENCE_SPACE_KEY='SPACE'
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
On Windows, these can be set via system properties.
|
|
@@ -129,7 +129,7 @@ The above tells the tool to synchronize the Markdown file with the given Conflue
|
|
|
129
129
|
If you work in an environment where there are multiple Confluence spaces, and some Markdown pages may go into one space, whereas other pages may go into another, you can set the target space on a per-document basis:
|
|
130
130
|
|
|
131
131
|
```markdown
|
|
132
|
-
<!-- confluence-space-key:
|
|
132
|
+
<!-- confluence-space-key: SPACE -->
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
This overrides the default space set via command-line arguments or environment variables.
|
|
@@ -146,9 +146,17 @@ Provide generated-by prompt text in the Markdown file with a tag:
|
|
|
146
146
|
|
|
147
147
|
Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes precedence.
|
|
148
148
|
|
|
149
|
+
### Publishing a single page
|
|
150
|
+
|
|
151
|
+
*md2conf* has two modes of operation: *single-page mode* and *directory mode*.
|
|
152
|
+
|
|
153
|
+
In single-page mode, you specify a single Markdown file as the source, which can contain absolute links to external locations (e.g. `https://example.com`) but not relative links to other pages (e.g. `local.md`). In other words, the page must be stand-alone.
|
|
154
|
+
|
|
149
155
|
### Publishing a directory
|
|
150
156
|
|
|
151
|
-
*md2conf* allows you to convert and publish a directory of Markdown files rather than a single Markdown file if you pass a directory as
|
|
157
|
+
*md2conf* allows you to convert and publish a directory of Markdown files rather than a single Markdown file in *directory mode* if you pass a directory as the source. This will traverse the specified directory recursively, and synchronize each Markdown file.
|
|
158
|
+
|
|
159
|
+
First, *md2conf* builds an index of pages in the directory hierarchy. The index maps each Markdown file path to a Confluence page ID. Whenever a relative link is encountered in a Markdown file, the relative link is replaced with a Confluence URL to the referenced page with the help of the index. All relative links must point to Markdown files that are located in the directory hierarchy.
|
|
152
160
|
|
|
153
161
|
If a Markdown file doesn't yet pair up with a Confluence page, *md2conf* creates a new page and assigns a parent. Parent-child relationships are reflected in the navigation panel in Confluence. You can set a root page ID with the command-line option `-r`, which constitutes the topmost parent. (This could correspond to the landing page of your Confluence space. The Confluence page ID is always revealed when you edit a page.) Whenever a directory contains the file `index.md` or `README.md`, this page becomes the future parent page, and all Markdown files in this directory (and possibly nested directories) become its child pages (unless they already have a page ID). However, if an `index.md` or `README.md` file is subsequently found in one of the nested directories, it becomes the parent page of that directory, and any of its subdirectories.
|
|
154
162
|
|
|
@@ -216,7 +224,7 @@ You can run the Docker container via `docker run` or via `Dockerfile`. Either ca
|
|
|
216
224
|
With `docker run`, you can pass Confluence domain, user, API and space key directly to `docker run`:
|
|
217
225
|
|
|
218
226
|
```sh
|
|
219
|
-
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d
|
|
227
|
+
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d example.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s SPACE ./
|
|
220
228
|
```
|
|
221
229
|
|
|
222
230
|
Alternatively, you can use a separate file `.env` to pass these parameters as environment variables:
|
|
@@ -234,11 +242,11 @@ With the `Dockerfile` approach, you can extend the base image:
|
|
|
234
242
|
```Dockerfile
|
|
235
243
|
FROM leventehunyadi/md2conf:latest
|
|
236
244
|
|
|
237
|
-
ENV CONFLUENCE_DOMAIN='
|
|
245
|
+
ENV CONFLUENCE_DOMAIN='example.atlassian.net'
|
|
238
246
|
ENV CONFLUENCE_PATH='/wiki/'
|
|
239
247
|
ENV CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
|
|
240
248
|
ENV CONFLUENCE_API_KEY='0123456789abcdef'
|
|
241
|
-
ENV CONFLUENCE_SPACE_KEY='
|
|
249
|
+
ENV CONFLUENCE_SPACE_KEY='SPACE'
|
|
242
250
|
|
|
243
251
|
CMD ["./"]
|
|
244
252
|
```
|
|
@@ -248,5 +256,5 @@ Alternatively,
|
|
|
248
256
|
```Dockerfile
|
|
249
257
|
FROM leventehunyadi/md2conf:latest
|
|
250
258
|
|
|
251
|
-
CMD ["-d", "
|
|
259
|
+
CMD ["-d", "example.atlassian.net", "-u", "levente.hunyadi@instructure.com", "-a", "0123456789abcdef", "-s", "SPACE", "./"]
|
|
252
260
|
```
|
|
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
|
|
|
5
5
|
Confluence API endpoints to upload images and content.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "0.2.
|
|
8
|
+
__version__ = "0.2.6"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
10
|
__copyright__ = "Copyright 2022-2024, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Parses Markdown files, converts Markdown content into the Confluence Storage Format (XHTML), and invokes
|
|
5
|
+
Confluence API endpoints to upload images and content.
|
|
6
|
+
|
|
7
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
8
|
+
|
|
9
|
+
:see: https://github.com/hunyadi/md2conf
|
|
10
|
+
"""
|
|
11
|
+
|
|
1
12
|
import argparse
|
|
2
13
|
import logging
|
|
3
14
|
import os.path
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import io
|
|
2
10
|
import json
|
|
3
11
|
import logging
|
|
@@ -170,17 +178,30 @@ class ConfluenceSession:
|
|
|
170
178
|
def upload_attachment(
|
|
171
179
|
self,
|
|
172
180
|
page_id: str,
|
|
173
|
-
attachment_path: Path,
|
|
174
181
|
attachment_name: str,
|
|
182
|
+
*,
|
|
183
|
+
attachment_path: Optional[Path] = None,
|
|
175
184
|
raw_data: Optional[bytes] = None,
|
|
185
|
+
content_type: Optional[str] = None,
|
|
176
186
|
comment: Optional[str] = None,
|
|
177
|
-
*,
|
|
178
187
|
space_key: Optional[str] = None,
|
|
179
188
|
force: bool = False,
|
|
180
189
|
) -> None:
|
|
181
|
-
content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
|
|
182
190
|
|
|
183
|
-
if
|
|
191
|
+
if attachment_path is None and raw_data is None:
|
|
192
|
+
raise ConfluenceError("required: `attachment_path` or `raw_data`")
|
|
193
|
+
|
|
194
|
+
if attachment_path is not None and raw_data is not None:
|
|
195
|
+
raise ConfluenceError("expected: either `attachment_path` or `raw_data`")
|
|
196
|
+
|
|
197
|
+
if content_type is None:
|
|
198
|
+
if attachment_path is not None:
|
|
199
|
+
name = str(attachment_path)
|
|
200
|
+
else:
|
|
201
|
+
name = attachment_name
|
|
202
|
+
content_type, _ = mimetypes.guess_type(name, strict=True)
|
|
203
|
+
|
|
204
|
+
if attachment_path is not None and not attachment_path.is_file():
|
|
184
205
|
raise ConfluenceError(f"file not found: {attachment_path}")
|
|
185
206
|
|
|
186
207
|
try:
|
|
@@ -188,14 +209,16 @@ class ConfluenceSession:
|
|
|
188
209
|
page_id, attachment_name, space_key=space_key
|
|
189
210
|
)
|
|
190
211
|
|
|
191
|
-
if not
|
|
212
|
+
if attachment_path is not None:
|
|
192
213
|
if not force and attachment.file_size == attachment_path.stat().st_size:
|
|
193
214
|
LOGGER.info("Up-to-date attachment: %s", attachment_name)
|
|
194
215
|
return
|
|
195
|
-
|
|
216
|
+
elif raw_data is not None:
|
|
196
217
|
if not force and attachment.file_size == len(raw_data):
|
|
197
218
|
LOGGER.info("Up-to-date embedded image: %s", attachment_name)
|
|
198
219
|
return
|
|
220
|
+
else:
|
|
221
|
+
raise NotImplementedError("never occurs")
|
|
199
222
|
|
|
200
223
|
id = removeprefix(attachment.id, "att")
|
|
201
224
|
path = f"/content/{page_id}/child/attachment/{id}/data"
|
|
@@ -205,7 +228,7 @@ class ConfluenceSession:
|
|
|
205
228
|
|
|
206
229
|
url = self._build_url(path)
|
|
207
230
|
|
|
208
|
-
if not
|
|
231
|
+
if attachment_path is not None:
|
|
209
232
|
with open(attachment_path, "rb") as attachment_file:
|
|
210
233
|
file_to_upload = {
|
|
211
234
|
"comment": comment,
|
|
@@ -222,24 +245,27 @@ class ConfluenceSession:
|
|
|
222
245
|
files=file_to_upload, # type: ignore
|
|
223
246
|
headers={"X-Atlassian-Token": "no-check"},
|
|
224
247
|
)
|
|
225
|
-
|
|
248
|
+
elif raw_data is not None:
|
|
226
249
|
LOGGER.info("Uploading raw data: %s", attachment_name)
|
|
227
250
|
|
|
251
|
+
raw_file = io.BytesIO(raw_data)
|
|
252
|
+
raw_file.name = attachment_name
|
|
228
253
|
file_to_upload = {
|
|
229
254
|
"comment": comment,
|
|
230
255
|
"file": (
|
|
231
256
|
attachment_name, # will truncate path component
|
|
232
|
-
|
|
257
|
+
raw_file, # type: ignore
|
|
233
258
|
content_type,
|
|
234
259
|
{"Expires": "0"},
|
|
235
260
|
),
|
|
236
261
|
}
|
|
237
|
-
|
|
238
262
|
response = self.session.post(
|
|
239
263
|
url,
|
|
240
264
|
files=file_to_upload, # type: ignore
|
|
241
265
|
headers={"X-Atlassian-Token": "no-check"},
|
|
242
266
|
)
|
|
267
|
+
else:
|
|
268
|
+
raise NotImplementedError("never occurs")
|
|
243
269
|
|
|
244
270
|
response.raise_for_status()
|
|
245
271
|
data = response.json()
|
|
@@ -491,7 +517,7 @@ class ConfluenceSession:
|
|
|
491
517
|
page_id = self.page_exists(title)
|
|
492
518
|
|
|
493
519
|
if page_id is not None:
|
|
494
|
-
LOGGER.debug("Retrieving existing page: %
|
|
520
|
+
LOGGER.debug("Retrieving existing page: %s", page_id)
|
|
495
521
|
return self.get_page(page_id)
|
|
496
522
|
else:
|
|
497
523
|
LOGGER.debug("Creating new page with title: %s", title)
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import logging
|
|
2
10
|
import os.path
|
|
3
11
|
from pathlib import Path
|
|
@@ -36,6 +44,7 @@ class Application:
|
|
|
36
44
|
def synchronize(self, path: Path) -> None:
|
|
37
45
|
"Synchronizes a single Markdown page or a directory of Markdown pages."
|
|
38
46
|
|
|
47
|
+
path = path.resolve(True)
|
|
39
48
|
if path.is_dir():
|
|
40
49
|
self.synchronize_directory(path)
|
|
41
50
|
elif path.is_file():
|
|
@@ -43,15 +52,31 @@ class Application:
|
|
|
43
52
|
else:
|
|
44
53
|
raise ValueError(f"expected: valid file or directory path; got: {path}")
|
|
45
54
|
|
|
46
|
-
def synchronize_page(
|
|
55
|
+
def synchronize_page(
|
|
56
|
+
self, page_path: Path, root_dir: Optional[Path] = None
|
|
57
|
+
) -> None:
|
|
47
58
|
"Synchronizes a single Markdown page with Confluence."
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
page_path = page_path.resolve(True)
|
|
61
|
+
if root_dir is None:
|
|
62
|
+
root_dir = page_path.parent
|
|
63
|
+
else:
|
|
64
|
+
root_dir = root_dir.resolve(True)
|
|
65
|
+
|
|
66
|
+
self._synchronize_page(page_path, root_dir, {})
|
|
50
67
|
|
|
51
|
-
def synchronize_directory(
|
|
68
|
+
def synchronize_directory(
|
|
69
|
+
self, local_dir: Path, root_dir: Optional[Path] = None
|
|
70
|
+
) -> None:
|
|
52
71
|
"Synchronizes a directory of Markdown pages with Confluence."
|
|
53
72
|
|
|
54
|
-
|
|
73
|
+
local_dir = local_dir.resolve(True)
|
|
74
|
+
if root_dir is None:
|
|
75
|
+
root_dir = local_dir
|
|
76
|
+
else:
|
|
77
|
+
root_dir = root_dir.resolve(True)
|
|
78
|
+
|
|
79
|
+
LOGGER.info("Synchronizing directory: %s", local_dir)
|
|
55
80
|
|
|
56
81
|
# Step 1: build index of all page metadata
|
|
57
82
|
page_metadata: Dict[Path, ConfluencePageMetadata] = {}
|
|
@@ -61,21 +86,22 @@ class Application:
|
|
|
61
86
|
else None
|
|
62
87
|
)
|
|
63
88
|
self._index_directory(local_dir, root_id, page_metadata)
|
|
64
|
-
LOGGER.info(
|
|
89
|
+
LOGGER.info("Indexed %d page(s)", len(page_metadata))
|
|
65
90
|
|
|
66
91
|
# Step 2: convert each page
|
|
67
92
|
for page_path in page_metadata.keys():
|
|
68
|
-
self._synchronize_page(page_path, page_metadata)
|
|
93
|
+
self._synchronize_page(page_path, root_dir, page_metadata)
|
|
69
94
|
|
|
70
95
|
def _synchronize_page(
|
|
71
96
|
self,
|
|
72
97
|
page_path: Path,
|
|
98
|
+
root_dir: Path,
|
|
73
99
|
page_metadata: Dict[Path, ConfluencePageMetadata],
|
|
74
100
|
) -> None:
|
|
75
101
|
base_path = page_path.parent
|
|
76
102
|
|
|
77
|
-
LOGGER.info(
|
|
78
|
-
document = ConfluenceDocument(page_path, self.options, page_metadata)
|
|
103
|
+
LOGGER.info("Synchronizing page: %s", page_path)
|
|
104
|
+
document = ConfluenceDocument(page_path, self.options, root_dir, page_metadata)
|
|
79
105
|
|
|
80
106
|
if document.id.space_key:
|
|
81
107
|
with self.api.switch_space(document.id.space_key):
|
|
@@ -91,7 +117,7 @@ class Application:
|
|
|
91
117
|
) -> None:
|
|
92
118
|
"Indexes Markdown files in a directory recursively."
|
|
93
119
|
|
|
94
|
-
LOGGER.info(
|
|
120
|
+
LOGGER.info("Indexing directory: %s", local_dir)
|
|
95
121
|
|
|
96
122
|
matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
|
|
97
123
|
|
|
@@ -107,22 +133,30 @@ class Application:
|
|
|
107
133
|
directories.append(Path(local_dir) / entry.name)
|
|
108
134
|
|
|
109
135
|
# make page act as parent node in Confluence
|
|
110
|
-
|
|
136
|
+
parent_doc: Optional[Path] = None
|
|
111
137
|
if (Path(local_dir) / "index.md") in files:
|
|
112
|
-
|
|
138
|
+
parent_doc = Path(local_dir) / "index.md"
|
|
113
139
|
elif (Path(local_dir) / "README.md") in files:
|
|
114
|
-
|
|
140
|
+
parent_doc = Path(local_dir) / "README.md"
|
|
141
|
+
|
|
142
|
+
if parent_doc is not None:
|
|
143
|
+
files.remove(parent_doc)
|
|
115
144
|
|
|
116
|
-
|
|
145
|
+
metadata = self._get_or_create_page(parent_doc, root_id)
|
|
146
|
+
LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
|
|
147
|
+
page_metadata[parent_doc] = metadata
|
|
148
|
+
|
|
149
|
+
parent_id = read_qualified_id(parent_doc) or root_id
|
|
150
|
+
else:
|
|
117
151
|
parent_id = root_id
|
|
118
152
|
|
|
119
153
|
for doc in files:
|
|
120
154
|
metadata = self._get_or_create_page(doc, parent_id)
|
|
121
|
-
LOGGER.debug(
|
|
155
|
+
LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
|
|
122
156
|
page_metadata[doc] = metadata
|
|
123
157
|
|
|
124
158
|
for directory in directories:
|
|
125
|
-
self._index_directory(
|
|
159
|
+
self._index_directory(directory, parent_id, page_metadata)
|
|
126
160
|
|
|
127
161
|
def _get_or_create_page(
|
|
128
162
|
self,
|
|
@@ -202,20 +236,19 @@ class Application:
|
|
|
202
236
|
for image in document.images:
|
|
203
237
|
self.api.upload_attachment(
|
|
204
238
|
document.id.page_id,
|
|
205
|
-
base_path / image,
|
|
206
239
|
attachment_name(image),
|
|
240
|
+
attachment_path=base_path / image,
|
|
207
241
|
)
|
|
208
242
|
|
|
209
|
-
for
|
|
243
|
+
for name, data in document.embedded_images.items():
|
|
210
244
|
self.api.upload_attachment(
|
|
211
245
|
document.id.page_id,
|
|
212
|
-
|
|
213
|
-
attachment_name(image),
|
|
246
|
+
name,
|
|
214
247
|
raw_data=data,
|
|
215
248
|
)
|
|
216
249
|
|
|
217
250
|
content = document.xhtml()
|
|
218
|
-
LOGGER.debug(
|
|
251
|
+
LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
|
|
219
252
|
self.api.update_page(document.id.page_id, content)
|
|
220
253
|
|
|
221
254
|
def _update_markdown(
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
# mypy: disable-error-code="dict-item"
|
|
2
10
|
|
|
3
11
|
import hashlib
|
|
@@ -10,7 +18,7 @@ import uuid
|
|
|
10
18
|
import xml.etree.ElementTree
|
|
11
19
|
from dataclasses import dataclass
|
|
12
20
|
from pathlib import Path
|
|
13
|
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
21
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
|
14
22
|
from urllib.parse import ParseResult, urlparse, urlunparse
|
|
15
23
|
|
|
16
24
|
import lxml.etree as ET
|
|
@@ -293,9 +301,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
293
301
|
|
|
294
302
|
options: ConfluenceConverterOptions
|
|
295
303
|
path: Path
|
|
296
|
-
|
|
304
|
+
base_dir: Path
|
|
305
|
+
root_dir: Path
|
|
297
306
|
links: List[str]
|
|
298
|
-
images: List[
|
|
307
|
+
images: List[Path]
|
|
299
308
|
embedded_images: Dict[str, bytes]
|
|
300
309
|
page_metadata: Dict[Path, ConfluencePageMetadata]
|
|
301
310
|
|
|
@@ -303,12 +312,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
303
312
|
self,
|
|
304
313
|
options: ConfluenceConverterOptions,
|
|
305
314
|
path: Path,
|
|
315
|
+
root_dir: Path,
|
|
306
316
|
page_metadata: Dict[Path, ConfluencePageMetadata],
|
|
307
317
|
) -> None:
|
|
308
318
|
super().__init__()
|
|
309
319
|
self.options = options
|
|
310
320
|
self.path = path
|
|
311
|
-
self.
|
|
321
|
+
self.base_dir = path.parent
|
|
322
|
+
self.root_dir = root_dir
|
|
312
323
|
self.links = []
|
|
313
324
|
self.images = []
|
|
314
325
|
self.embedded_images = {}
|
|
@@ -343,7 +354,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
343
354
|
if is_absolute_url(url):
|
|
344
355
|
return None
|
|
345
356
|
|
|
346
|
-
LOGGER.debug(
|
|
357
|
+
LOGGER.debug("Found link %s relative to %s", url, self.path)
|
|
347
358
|
relative_url: ParseResult = urlparse(url)
|
|
348
359
|
|
|
349
360
|
if (
|
|
@@ -353,7 +364,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
353
364
|
and not relative_url.params
|
|
354
365
|
and not relative_url.query
|
|
355
366
|
):
|
|
356
|
-
LOGGER.debug(
|
|
367
|
+
LOGGER.debug("Found local URL: %s", url)
|
|
357
368
|
if self.options.heading_anchors:
|
|
358
369
|
# <ac:link ac:anchor="anchor"><ac:link-body>...</ac:link-body></ac:link>
|
|
359
370
|
target = relative_url.fragment.lstrip("#")
|
|
@@ -375,9 +386,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
375
386
|
# convert the relative URL to absolute URL based on the base path value, then look up
|
|
376
387
|
# the absolute path in the page metadata dictionary to discover the relative path
|
|
377
388
|
# within Confluence that should be used
|
|
378
|
-
absolute_path = (self.
|
|
379
|
-
if not str(absolute_path).startswith(str(self.
|
|
380
|
-
msg = f"relative URL {url} points to outside
|
|
389
|
+
absolute_path = (self.base_dir / relative_url.path).resolve(True)
|
|
390
|
+
if not str(absolute_path).startswith(str(self.root_dir)):
|
|
391
|
+
msg = f"relative URL {url} points to outside root path: {self.root_dir}"
|
|
381
392
|
if self.options.ignore_invalid_url:
|
|
382
393
|
LOGGER.warning(msg)
|
|
383
394
|
anchor.attrib.pop("href")
|
|
@@ -385,8 +396,6 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
385
396
|
else:
|
|
386
397
|
raise DocumentError(msg)
|
|
387
398
|
|
|
388
|
-
relative_path = os.path.relpath(absolute_path, self.base_path)
|
|
389
|
-
|
|
390
399
|
link_metadata = self.page_metadata.get(absolute_path)
|
|
391
400
|
if link_metadata is None:
|
|
392
401
|
msg = f"unable to find matching page for URL: {url}"
|
|
@@ -397,8 +406,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
397
406
|
else:
|
|
398
407
|
raise DocumentError(msg)
|
|
399
408
|
|
|
409
|
+
relative_path = os.path.relpath(absolute_path, self.base_dir)
|
|
400
410
|
LOGGER.debug(
|
|
401
|
-
|
|
411
|
+
"found link to page %s with metadata: %s", relative_path, link_metadata
|
|
402
412
|
)
|
|
403
413
|
self.links.append(url)
|
|
404
414
|
|
|
@@ -417,24 +427,31 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
417
427
|
)
|
|
418
428
|
transformed_url = urlunparse(components)
|
|
419
429
|
|
|
420
|
-
LOGGER.debug(
|
|
430
|
+
LOGGER.debug("Transformed relative URL: %s to URL: %s", url, transformed_url)
|
|
421
431
|
anchor.attrib["href"] = transformed_url
|
|
422
432
|
return None
|
|
423
433
|
|
|
424
434
|
def _transform_image(self, image: ET._Element) -> ET._Element:
|
|
425
435
|
path: str = image.attrib["src"]
|
|
426
436
|
|
|
437
|
+
if not path:
|
|
438
|
+
raise DocumentError("image lacks `src` attribute")
|
|
439
|
+
|
|
440
|
+
if is_absolute_url(path):
|
|
441
|
+
# images whose `src` attribute is an absolute URL cannot be converted into an `ac:image`;
|
|
442
|
+
# Confluence images are expected to refer to an uploaded attachment
|
|
443
|
+
raise DocumentError("image has a `src` attribute that is an absolute URL")
|
|
444
|
+
|
|
445
|
+
relative_path = Path(path)
|
|
446
|
+
|
|
427
447
|
# prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
):
|
|
434
|
-
path = str(relative_path.with_suffix(".png"))
|
|
435
|
-
|
|
436
|
-
self.images.append(path)
|
|
448
|
+
png_file = relative_path.with_suffix(".png")
|
|
449
|
+
if relative_path.suffix == ".svg" and (self.base_dir / png_file).exists():
|
|
450
|
+
relative_path = png_file
|
|
451
|
+
|
|
452
|
+
self.images.append(relative_path)
|
|
437
453
|
caption = image.attrib["alt"]
|
|
454
|
+
image_name = attachment_name(relative_path)
|
|
438
455
|
return AC(
|
|
439
456
|
"image",
|
|
440
457
|
{
|
|
@@ -443,7 +460,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
443
460
|
},
|
|
444
461
|
RI(
|
|
445
462
|
"attachment",
|
|
446
|
-
|
|
463
|
+
# refers to an attachment uploaded alongside the page
|
|
464
|
+
{ET.QName(namespaces["ri"], "filename"): image_name},
|
|
447
465
|
),
|
|
448
466
|
AC("caption", HTML.p(caption)),
|
|
449
467
|
)
|
|
@@ -749,6 +767,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
749
767
|
tail: str = child.tail
|
|
750
768
|
child.tail = tail.replace("\n", " ")
|
|
751
769
|
|
|
770
|
+
if not isinstance(child.tag, str):
|
|
771
|
+
return None
|
|
772
|
+
|
|
752
773
|
if self.options.heading_anchors:
|
|
753
774
|
# <h1>...</h1>
|
|
754
775
|
# <h2>...</h2> ...
|
|
@@ -924,7 +945,7 @@ class ConfluenceDocumentOptions:
|
|
|
924
945
|
class ConfluenceDocument:
|
|
925
946
|
id: ConfluenceQualifiedID
|
|
926
947
|
links: List[str]
|
|
927
|
-
images: List[
|
|
948
|
+
images: List[Path]
|
|
928
949
|
|
|
929
950
|
options: ConfluenceDocumentOptions
|
|
930
951
|
root: ET._Element
|
|
@@ -933,10 +954,11 @@ class ConfluenceDocument:
|
|
|
933
954
|
self,
|
|
934
955
|
path: Path,
|
|
935
956
|
options: ConfluenceDocumentOptions,
|
|
957
|
+
root_dir: Path,
|
|
936
958
|
page_metadata: Dict[Path, ConfluencePageMetadata],
|
|
937
959
|
) -> None:
|
|
938
960
|
self.options = options
|
|
939
|
-
path = path.
|
|
961
|
+
path = path.resolve(True)
|
|
940
962
|
|
|
941
963
|
with open(path, "r", encoding="utf-8") as f:
|
|
942
964
|
text = f.read()
|
|
@@ -990,6 +1012,7 @@ class ConfluenceDocument:
|
|
|
990
1012
|
webui_links=self.options.webui_links,
|
|
991
1013
|
),
|
|
992
1014
|
path,
|
|
1015
|
+
root_dir,
|
|
993
1016
|
page_metadata,
|
|
994
1017
|
)
|
|
995
1018
|
converter.visit(self.root)
|
|
@@ -1001,7 +1024,7 @@ class ConfluenceDocument:
|
|
|
1001
1024
|
return elements_to_string(self.root)
|
|
1002
1025
|
|
|
1003
1026
|
|
|
1004
|
-
def attachment_name(name: str) -> str:
|
|
1027
|
+
def attachment_name(name: Union[Path, str]) -> str:
|
|
1005
1028
|
"""
|
|
1006
1029
|
Safe name for use with attachment uploads.
|
|
1007
1030
|
|
|
@@ -1010,7 +1033,7 @@ def attachment_name(name: str) -> str:
|
|
|
1010
1033
|
* Special characters: hyphen (-), underscore (_), period (.)
|
|
1011
1034
|
"""
|
|
1012
1035
|
|
|
1013
|
-
return re.sub(r"[^\-0-9A-Za-z_.]", "_", name)
|
|
1036
|
+
return re.sub(r"[^\-0-9A-Za-z_.]", "_", str(name))
|
|
1014
1037
|
|
|
1015
1038
|
|
|
1016
1039
|
def sanitize_confluence(html: str) -> str:
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import logging
|
|
2
10
|
import os
|
|
3
11
|
import os.path
|
|
@@ -48,11 +56,15 @@ def render(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
|
|
|
48
56
|
filename,
|
|
49
57
|
"--outputFormat",
|
|
50
58
|
output_format,
|
|
59
|
+
"--backgroundColor",
|
|
60
|
+
"transparent",
|
|
61
|
+
"--scale",
|
|
62
|
+
"2",
|
|
51
63
|
]
|
|
52
64
|
root = os.path.dirname(__file__)
|
|
53
65
|
if is_docker():
|
|
54
66
|
cmd.extend(["-p", os.path.join(root, "puppeteer-config.json")])
|
|
55
|
-
LOGGER.debug(
|
|
67
|
+
LOGGER.debug("Executing: %s", " ".join(cmd))
|
|
56
68
|
try:
|
|
57
69
|
proc = subprocess.Popen(
|
|
58
70
|
cmd,
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import hashlib
|
|
2
10
|
import logging
|
|
3
11
|
import os
|
|
4
12
|
from pathlib import Path
|
|
5
|
-
from typing import Dict, List
|
|
13
|
+
from typing import Dict, List, Optional
|
|
6
14
|
|
|
7
15
|
from .converter import (
|
|
8
16
|
ConfluenceDocument,
|
|
@@ -30,33 +38,56 @@ class Processor:
|
|
|
30
38
|
def process(self, path: Path) -> None:
|
|
31
39
|
"Processes a single Markdown file or a directory of Markdown files."
|
|
32
40
|
|
|
41
|
+
path = path.resolve(True)
|
|
33
42
|
if path.is_dir():
|
|
34
43
|
self.process_directory(path)
|
|
35
44
|
elif path.is_file():
|
|
36
|
-
self.process_page(path
|
|
45
|
+
self.process_page(path)
|
|
37
46
|
else:
|
|
38
47
|
raise ValueError(f"expected: valid file or directory path; got: {path}")
|
|
39
48
|
|
|
40
|
-
def process_directory(
|
|
49
|
+
def process_directory(
|
|
50
|
+
self, local_dir: Path, root_dir: Optional[Path] = None
|
|
51
|
+
) -> None:
|
|
41
52
|
"Recursively scans a directory hierarchy for Markdown files."
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
local_dir = local_dir.resolve(True)
|
|
55
|
+
if root_dir is None:
|
|
56
|
+
root_dir = local_dir
|
|
57
|
+
else:
|
|
58
|
+
root_dir = root_dir.resolve(True)
|
|
59
|
+
|
|
60
|
+
LOGGER.info("Synchronizing directory: %s", local_dir)
|
|
44
61
|
|
|
45
62
|
# Step 1: build index of all page metadata
|
|
46
63
|
page_metadata: Dict[Path, ConfluencePageMetadata] = {}
|
|
47
64
|
self._index_directory(local_dir, page_metadata)
|
|
48
|
-
LOGGER.info(
|
|
65
|
+
LOGGER.info("Indexed %d page(s)", len(page_metadata))
|
|
49
66
|
|
|
50
67
|
# Step 2: convert each page
|
|
51
68
|
for page_path in page_metadata.keys():
|
|
52
|
-
self.
|
|
69
|
+
self._process_page(page_path, root_dir, page_metadata)
|
|
53
70
|
|
|
54
|
-
def process_page(
|
|
55
|
-
|
|
71
|
+
def process_page(self, path: Path, root_dir: Optional[Path] = None) -> None:
|
|
72
|
+
"Processes a single Markdown file."
|
|
73
|
+
|
|
74
|
+
path = path.resolve(True)
|
|
75
|
+
if root_dir is None:
|
|
76
|
+
root_dir = path.parent
|
|
77
|
+
else:
|
|
78
|
+
root_dir = root_dir.resolve(True)
|
|
79
|
+
|
|
80
|
+
self._process_page(path, root_dir, {})
|
|
81
|
+
|
|
82
|
+
def _process_page(
|
|
83
|
+
self,
|
|
84
|
+
path: Path,
|
|
85
|
+
root_dir: Path,
|
|
86
|
+
page_metadata: Dict[Path, ConfluencePageMetadata],
|
|
56
87
|
) -> None:
|
|
57
88
|
"Processes a single Markdown file."
|
|
58
89
|
|
|
59
|
-
document = ConfluenceDocument(path, self.options, page_metadata)
|
|
90
|
+
document = ConfluenceDocument(path, self.options, root_dir, page_metadata)
|
|
60
91
|
content = document.xhtml()
|
|
61
92
|
with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
|
|
62
93
|
f.write(content)
|
|
@@ -68,7 +99,7 @@ class Processor:
|
|
|
68
99
|
) -> None:
|
|
69
100
|
"Indexes Markdown files in a directory recursively."
|
|
70
101
|
|
|
71
|
-
LOGGER.info(
|
|
102
|
+
LOGGER.info("Indexing directory: %s", local_dir)
|
|
72
103
|
|
|
73
104
|
matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
|
|
74
105
|
|
|
@@ -85,11 +116,11 @@ class Processor:
|
|
|
85
116
|
|
|
86
117
|
for doc in files:
|
|
87
118
|
metadata = self._get_page(doc)
|
|
88
|
-
LOGGER.debug(
|
|
119
|
+
LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
|
|
89
120
|
page_metadata[doc] = metadata
|
|
90
121
|
|
|
91
122
|
for directory in directories:
|
|
92
|
-
self._index_directory(
|
|
123
|
+
self._index_directory(directory, page_metadata)
|
|
93
124
|
|
|
94
125
|
def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
|
|
95
126
|
"Extracts metadata from a Markdown file."
|
|
@@ -102,7 +133,7 @@ class Processor:
|
|
|
102
133
|
if self.options.root_page_id is not None:
|
|
103
134
|
hash = hashlib.md5(document.encode("utf-8"))
|
|
104
135
|
digest = "".join(f"{c:x}" for c in hash.digest())
|
|
105
|
-
LOGGER.info(
|
|
136
|
+
LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
|
|
106
137
|
qualified_id = ConfluenceQualifiedID(digest)
|
|
107
138
|
else:
|
|
108
139
|
raise ValueError("required: page ID for local output")
|
|
@@ -29,10 +29,10 @@ packages = find:
|
|
|
29
29
|
python_requires = >=3.8
|
|
30
30
|
install_requires =
|
|
31
31
|
lxml >= 5.3
|
|
32
|
-
types-lxml >= 2024.8
|
|
33
|
-
markdown >= 3.
|
|
34
|
-
types-markdown >= 3.
|
|
35
|
-
pymdown-extensions >= 10.
|
|
32
|
+
types-lxml >= 2024.11.8
|
|
33
|
+
markdown >= 3.7
|
|
34
|
+
types-markdown >= 3.7
|
|
35
|
+
pymdown-extensions >= 10.12
|
|
36
36
|
pyyaml >= 6.0
|
|
37
37
|
types-PyYAML >= 6.0
|
|
38
38
|
requests >= 2.32
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import logging
|
|
2
10
|
import os
|
|
3
11
|
import os.path
|
|
@@ -65,7 +73,8 @@ class TestConversion(unittest.TestCase):
|
|
|
65
73
|
with self.subTest(name=name):
|
|
66
74
|
actual = ConfluenceDocument(
|
|
67
75
|
self.source_dir / f"{name}.md",
|
|
68
|
-
ConfluenceDocumentOptions(
|
|
76
|
+
ConfluenceDocumentOptions(),
|
|
77
|
+
self.source_dir,
|
|
69
78
|
{},
|
|
70
79
|
).xhtml()
|
|
71
80
|
actual = standardize(actual)
|
|
@@ -75,10 +84,25 @@ class TestConversion(unittest.TestCase):
|
|
|
75
84
|
|
|
76
85
|
self.assertEqual(actual, expected)
|
|
77
86
|
|
|
87
|
+
def test_broken_links(self) -> None:
|
|
88
|
+
actual = ConfluenceDocument(
|
|
89
|
+
self.source_dir / "missing.md",
|
|
90
|
+
ConfluenceDocumentOptions(ignore_invalid_url=True),
|
|
91
|
+
self.source_dir,
|
|
92
|
+
{},
|
|
93
|
+
).xhtml()
|
|
94
|
+
actual = standardize(actual)
|
|
95
|
+
|
|
96
|
+
with open(self.target_dir / "missing.xml", "r", encoding="utf-8") as f:
|
|
97
|
+
expected = canonicalize(f.read())
|
|
98
|
+
|
|
99
|
+
self.assertEqual(actual, expected)
|
|
100
|
+
|
|
78
101
|
def test_heading_anchors(self) -> None:
|
|
79
102
|
actual = ConfluenceDocument(
|
|
80
103
|
self.source_dir / "anchors.md",
|
|
81
104
|
ConfluenceDocumentOptions(heading_anchors=True),
|
|
105
|
+
self.source_dir,
|
|
82
106
|
{},
|
|
83
107
|
).xhtml()
|
|
84
108
|
actual = standardize(actual)
|
|
@@ -93,10 +117,10 @@ class TestConversion(unittest.TestCase):
|
|
|
93
117
|
document = ConfluenceDocument(
|
|
94
118
|
self.source_dir / "mermaid.md",
|
|
95
119
|
ConfluenceDocumentOptions(
|
|
96
|
-
ignore_invalid_url=True,
|
|
97
120
|
render_mermaid=True,
|
|
98
121
|
diagram_output_format="svg",
|
|
99
122
|
),
|
|
123
|
+
self.source_dir,
|
|
100
124
|
{},
|
|
101
125
|
)
|
|
102
126
|
self.assertEqual(len(document.embedded_images), 6)
|
|
@@ -106,10 +130,10 @@ class TestConversion(unittest.TestCase):
|
|
|
106
130
|
document = ConfluenceDocument(
|
|
107
131
|
self.source_dir / "mermaid.md",
|
|
108
132
|
ConfluenceDocumentOptions(
|
|
109
|
-
ignore_invalid_url=True,
|
|
110
133
|
render_mermaid=True,
|
|
111
134
|
diagram_output_format="png",
|
|
112
135
|
),
|
|
136
|
+
self.source_dir,
|
|
113
137
|
{},
|
|
114
138
|
)
|
|
115
139
|
self.assertEqual(len(document.embedded_images), 6)
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import logging
|
|
2
10
|
import os
|
|
3
11
|
import os.path
|
|
@@ -36,6 +44,7 @@ class TestMatcher(unittest.TestCase):
|
|
|
36
44
|
]
|
|
37
45
|
expected.remove(Entry("ignore.md", False))
|
|
38
46
|
expected.remove(Entry("anchors.md", False))
|
|
47
|
+
expected.remove(Entry("missing.md", False))
|
|
39
48
|
|
|
40
49
|
options = MatcherOptions(".mdignore", ".md")
|
|
41
50
|
matcher = Matcher(options, directory)
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import logging
|
|
2
10
|
import os
|
|
3
11
|
import shutil
|
|
@@ -6,7 +14,6 @@ from pathlib import Path
|
|
|
6
14
|
|
|
7
15
|
from md2conf.mermaid import has_mmdc, render
|
|
8
16
|
|
|
9
|
-
|
|
10
17
|
logging.basicConfig(
|
|
11
18
|
level=logging.INFO,
|
|
12
19
|
format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2024, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import logging
|
|
2
10
|
import shutil
|
|
3
11
|
import unittest
|
|
@@ -31,7 +39,6 @@ class TestProcessor(unittest.TestCase):
|
|
|
31
39
|
|
|
32
40
|
def test_process_document(self) -> None:
|
|
33
41
|
options = ConfluenceDocumentOptions(
|
|
34
|
-
ignore_invalid_url=False,
|
|
35
42
|
generated_by="Test Case",
|
|
36
43
|
root_page_id="None",
|
|
37
44
|
)
|
|
@@ -45,7 +52,6 @@ class TestProcessor(unittest.TestCase):
|
|
|
45
52
|
|
|
46
53
|
def test_process_directory(self) -> None:
|
|
47
54
|
options = ConfluenceDocumentOptions(
|
|
48
|
-
ignore_invalid_url=True,
|
|
49
55
|
generated_by="The Author",
|
|
50
56
|
root_page_id="ROOT_PAGE_ID",
|
|
51
57
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|