markdown-to-confluence 0.4.5__py3-none-any.whl → 0.4.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/METADATA +35 -13
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/RECORD +18 -17
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +16 -35
- md2conf/api.py +84 -20
- md2conf/converter.py +69 -34
- md2conf/csf.py +1 -1
- md2conf/emoticon.py +22 -0
- md2conf/mermaid.py +18 -3
- md2conf/processor.py +1 -1
- md2conf/{application.py → publisher.py} +14 -11
- md2conf/scanner.py +46 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/zip-safe +0 -0
- /md2conf/{properties.py → environment.py} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Author-email: Levente Hunyadi <hunyadi@gmail.com>
|
|
6
6
|
Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
|
|
@@ -23,12 +23,14 @@ Classifier: Typing :: Typed
|
|
|
23
23
|
Requires-Python: >=3.9
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
|
+
Requires-Dist: certifi>=2025.8.3; python_version < "3.10"
|
|
26
27
|
Requires-Dist: json_strong_typing>=0.4
|
|
27
28
|
Requires-Dist: lxml>=6.0
|
|
28
29
|
Requires-Dist: markdown>=3.8
|
|
29
30
|
Requires-Dist: pymdown-extensions>=10.16
|
|
30
31
|
Requires-Dist: PyYAML>=6.0
|
|
31
32
|
Requires-Dist: requests>=2.32
|
|
33
|
+
Requires-Dist: truststore>=0.10; python_version >= "3.10"
|
|
32
34
|
Requires-Dist: typing-extensions>=4.14; python_version < "3.12"
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: markdown_doc>=0.1.4; python_version >= "3.10" and extra == "dev"
|
|
@@ -143,7 +145,7 @@ export CONFLUENCE_SPACE_KEY='SPACE'
|
|
|
143
145
|
|
|
144
146
|
On Windows, these can be set via system properties.
|
|
145
147
|
|
|
146
|
-
If you use Atlassian scoped API tokens, you
|
|
148
|
+
If you use Atlassian scoped API tokens, you may want to set API URL directly, substituting `CLOUD_ID` with your own Cloud ID:
|
|
147
149
|
|
|
148
150
|
```sh
|
|
149
151
|
export CONFLUENCE_API_URL='https://api.atlassian.com/ex/confluence/CLOUD_ID/'
|
|
@@ -151,20 +153,27 @@ export CONFLUENCE_API_URL='https://api.atlassian.com/ex/confluence/CLOUD_ID/'
|
|
|
151
153
|
|
|
152
154
|
In this case, *md2conf* can automatically determine `CONFLUENCE_DOMAIN` and `CONFLUENCE_PATH`.
|
|
153
155
|
|
|
156
|
+
If you can't find your `CLOUD_ID` but assign both `CONFLUENCE_DOMAIN` and `CONFLUENCE_PATH`, *md2conf* makes a best-effort attempt to determine `CONFLUENCE_API_URL`.
|
|
157
|
+
|
|
154
158
|
### Permissions
|
|
155
159
|
|
|
156
160
|
The tool requires appropriate permissions in Confluence in order to invoke endpoints.
|
|
157
161
|
|
|
158
|
-
|
|
162
|
+
We recommend the following scopes for scoped API tokens:
|
|
159
163
|
|
|
164
|
+
* `read:attachment:confluence`
|
|
165
|
+
* `read:content:confluence`
|
|
166
|
+
* `read:content-details:confluence`
|
|
167
|
+
* `read:label:confluence`
|
|
160
168
|
* `read:page:confluence`
|
|
161
|
-
* `write:page:confluence`
|
|
162
169
|
* `read:space:confluence`
|
|
163
|
-
* `write:space:confluence`
|
|
164
|
-
* `read:attachment:confluence`
|
|
165
170
|
* `write:attachment:confluence`
|
|
166
|
-
* `
|
|
171
|
+
* `write:content:confluence`
|
|
167
172
|
* `write:label:confluence`
|
|
173
|
+
* `write:page:confluence`
|
|
174
|
+
* `delete:attachment:confluence`
|
|
175
|
+
* `delete:content:confluence`
|
|
176
|
+
* `delete:page:confluence`
|
|
168
177
|
|
|
169
178
|
If a Confluence username is set, the tool uses HTTP *Basic* authentication to pass the username and the API key to Confluence REST API endpoints. If no username is provided, the tool authenticates with HTTP *Bearer*, and passes the API key as the bearer token.
|
|
170
179
|
|
|
@@ -331,6 +340,21 @@ The following table shows standard highlight colors (CSS `background-color`) tha
|
|
|
331
340
|
|
|
332
341
|
If your Markdown lists or tables don't appear in Confluence as expected, verify that the list or table is delimited by a blank line both before and after, as per strict Markdown syntax. While some previewers accept a more lenient syntax (e.g. an itemized list immediately following a paragraph), *md2conf* uses [Python-Markdown](https://python-markdown.github.io/) internally to convert Markdown into XHTML, which expects the Markdown document to adhere to the stricter syntax.
|
|
333
342
|
|
|
343
|
+
Likewise, if you have a nested list, make sure that nested items are indented by exactly ***four*** spaces as compared to the parent node:
|
|
344
|
+
|
|
345
|
+
```markdown
|
|
346
|
+
1. List item 1
|
|
347
|
+
* Nested item 1
|
|
348
|
+
1. Item 1
|
|
349
|
+
2. Item 2
|
|
350
|
+
* Nested item 2
|
|
351
|
+
- Item 3
|
|
352
|
+
- Item 4
|
|
353
|
+
2. List item 2
|
|
354
|
+
1. Nested item 3
|
|
355
|
+
2. Nested item 4
|
|
356
|
+
```
|
|
357
|
+
|
|
334
358
|
### Publishing images
|
|
335
359
|
|
|
336
360
|
Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
|
|
@@ -348,7 +372,7 @@ Inline formulas can be enclosed with `$` signs, or delimited with `\(` and `\)`,
|
|
|
348
372
|
|
|
349
373
|
Block formulas can be enclosed with `$$`, or wrapped in code blocks specifying the language `math`:
|
|
350
374
|
|
|
351
|
-
```
|
|
375
|
+
```markdown
|
|
352
376
|
$$\int _{a}^{b}f(x)dx=F(b)-F(a)$$
|
|
353
377
|
```
|
|
354
378
|
|
|
@@ -385,7 +409,7 @@ Displaying math formulas in Confluence requires the extension [LaTeX Math for Co
|
|
|
385
409
|
|
|
386
410
|
Use the pseudo-language `csf` in a Markdown code block to pass content directly to Confluence. The content must be a single XML node that conforms to Confluence Storage Format (typically an `ac:structured-macro`) but is otherwise not validated. The following example shows how to create a panel similar to an *info panel* but with custom background color and emoji. Notice that `ac:rich-text-body` uses XHTML, not Markdown.
|
|
387
411
|
|
|
388
|
-
````
|
|
412
|
+
````markdown
|
|
389
413
|
```csf
|
|
390
414
|
<ac:structured-macro ac:name="panel" ac:schema-version="1">
|
|
391
415
|
<ac:parameter ac:name="panelIcon">:slight_smile:</ac:parameter>
|
|
@@ -496,7 +520,7 @@ If *md2conf* encounters a Markdown link that points to a file in the directory h
|
|
|
496
520
|
|
|
497
521
|
*md2conf* implicitly defines some URLs, as if you included the following at the start of the Markdown document for each URL:
|
|
498
522
|
|
|
499
|
-
```
|
|
523
|
+
```markdown
|
|
500
524
|
[CUSTOM-URL]: https://example.com/path/to/resource
|
|
501
525
|
```
|
|
502
526
|
|
|
@@ -531,7 +555,7 @@ options:
|
|
|
531
555
|
--api-url API_URL Confluence API URL. Required for scoped tokens. Refer to documentation how to obtain one.
|
|
532
556
|
-u, --username USERNAME
|
|
533
557
|
Confluence user name.
|
|
534
|
-
-a, --
|
|
558
|
+
-a, --api-key API_KEY
|
|
535
559
|
Confluence API key. Refer to documentation how to obtain one.
|
|
536
560
|
-s, --space SPACE Confluence space key for pages to be published. If omitted, will default to user space.
|
|
537
561
|
-l, --loglevel {debug,info,warning,error,critical}
|
|
@@ -550,8 +574,6 @@ options:
|
|
|
550
574
|
--no-render-latex Inline LaTeX formulas in Confluence page. (Marketplace app required to display.)
|
|
551
575
|
--diagram-output-format {png,svg}
|
|
552
576
|
Format for rendering Mermaid and draw.io diagrams (default: 'png').
|
|
553
|
-
--render-mermaid-format FORMAT
|
|
554
|
-
Format for rendering Mermaid diagrams (default: 'png').
|
|
555
577
|
--heading-anchors Place an anchor at each section heading with GitHub-style same-page identifiers.
|
|
556
578
|
--no-heading-anchors Don't place an anchor at each section heading.
|
|
557
579
|
--ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
|
|
@@ -1,33 +1,34 @@
|
|
|
1
|
-
markdown_to_confluence-0.4.
|
|
2
|
-
md2conf/__init__.py,sha256
|
|
3
|
-
md2conf/__main__.py,sha256=
|
|
4
|
-
md2conf/api.py,sha256=
|
|
5
|
-
md2conf/application.py,sha256=PZDPUpoKjKBPTHwgVO20pGzTwER3paZuQbI-2_TWBgE,8563
|
|
1
|
+
markdown_to_confluence-0.4.6.dist-info/licenses/LICENSE,sha256=56L-Y0dyZwyVlINRJRz3PNw-ka-oLVaAq-7d8zo6qlc,1077
|
|
2
|
+
md2conf/__init__.py,sha256=-uM9--fczADkWeiQ7PSL-iFlkeCxy7lnd2KhxG3yFso,402
|
|
3
|
+
md2conf/__main__.py,sha256=LiJ06zVr3wroMSLgkMf65ehraqJZgktvuN66dkBJaOI,10929
|
|
4
|
+
md2conf/api.py,sha256=RcltUP8KhrhrUuVg7SqBqp-AvGZymjMXtUxpzRPGGGc,40847
|
|
6
5
|
md2conf/collection.py,sha256=EobgMRJgkYloWlY03NZJ52MRC_SGLpTVCHkltDbQyt0,837
|
|
7
|
-
md2conf/converter.py,sha256=
|
|
8
|
-
md2conf/csf.py,sha256=
|
|
6
|
+
md2conf/converter.py,sha256=olL2FSop5TFxMfVXU4zWk1Lj_fwGN0c73F2ZYv8PU6s,63250
|
|
7
|
+
md2conf/csf.py,sha256=VlB0rYRZ8u-bNDdzEvNy2XrJSxaL-MW_hVRHxcoBluU,6409
|
|
9
8
|
md2conf/domain.py,sha256=NpeGl-I9_rgKYCKKZT1Ygg3nl5U0-jJHYYrzDVpMSGQ,1965
|
|
10
9
|
md2conf/drawio.py,sha256=3RJFFzlp5a7SNVNCnwO_HCDfMy0DqYQeXfHWRPInOVE,8527
|
|
10
|
+
md2conf/emoticon.py,sha256=P2L5oQvnRXeVifJQ3sJ2Ck-6ptbxumq2vsT-rM0W0Ms,484
|
|
11
11
|
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
12
|
+
md2conf/environment.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
|
|
12
13
|
md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
|
|
13
14
|
md2conf/latex.py,sha256=yAClNclguPv-xWBMVWbqvYWLbyUHBVufc2aUzwyKHew,7586
|
|
14
15
|
md2conf/local.py,sha256=mvp2kA_eo6JUQ_rlM7zDdEFgBPVxMr3VKP_X1nsLjHE,3747
|
|
15
16
|
md2conf/markdown.py,sha256=czabU17tUfhSX1JQGiI_TrMrTmtoVThOwFu_To_Oi_w,3176
|
|
16
17
|
md2conf/matcher.py,sha256=m5rZjYZSjhKfdeKS8JdPq7cG861Mc6rVZBkrIOZTHGE,6916
|
|
17
|
-
md2conf/mermaid.py,sha256=
|
|
18
|
+
md2conf/mermaid.py,sha256=hGrITJVvhHprjQVoezQ1nQeo6a_lqNihF8L-oJ4t5rc,2633
|
|
18
19
|
md2conf/metadata.py,sha256=LzZM-oPNnzCULmLhF516tPlV5zZBknccwMHt8Nan-xg,1007
|
|
19
|
-
md2conf/processor.py,sha256=
|
|
20
|
-
md2conf/
|
|
20
|
+
md2conf/processor.py,sha256=62Yr33uNVEb11Q7SQ8m1KJHMoJozBPSzeUJQ0fwHig0,9733
|
|
21
|
+
md2conf/publisher.py,sha256=uSyYb9SOQzMkSGKKQqsoOL5S2CfkVIEbXaCx6CY0Suw,8696
|
|
21
22
|
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
22
23
|
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
-
md2conf/scanner.py,sha256=
|
|
24
|
+
md2conf/scanner.py,sha256=APtENrcEgbslVfjjUnUyB81QpOmPoJgwfww0G7onNOY,6578
|
|
24
25
|
md2conf/text.py,sha256=fHOrUaPXAjE4iRhHqFq-CiI-knpo4wvyHCWp0crewqA,1736
|
|
25
26
|
md2conf/toc.py,sha256=hpqqDbFgNJg5-ul8qWjOglI3Am0sbwR-TLwGN5G9Qo0,2447
|
|
26
27
|
md2conf/uri.py,sha256=KbLBdRFtZTQTZd8b4j0LtE8Pb68Ly0WkemF4iW-EAB4,1158
|
|
27
28
|
md2conf/xml.py,sha256=Ybf3Ctt6EurVvel0eb1KezF33_e_cDpMwlUqHi4kNLE,5411
|
|
28
|
-
markdown_to_confluence-0.4.
|
|
29
|
-
markdown_to_confluence-0.4.
|
|
30
|
-
markdown_to_confluence-0.4.
|
|
31
|
-
markdown_to_confluence-0.4.
|
|
32
|
-
markdown_to_confluence-0.4.
|
|
33
|
-
markdown_to_confluence-0.4.
|
|
29
|
+
markdown_to_confluence-0.4.6.dist-info/METADATA,sha256=zgUR1Bz7EnzbcAkZVPVKqL123Y0L4NeWzbhxyq5yVH4,34414
|
|
30
|
+
markdown_to_confluence-0.4.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
31
|
+
markdown_to_confluence-0.4.6.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
32
|
+
markdown_to_confluence-0.4.6.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
33
|
+
markdown_to_confluence-0.4.6.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
34
|
+
markdown_to_confluence-0.4.6.dist-info/RECORD,,
|
md2conf/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
|
|
|
5
5
|
Confluence API endpoints to upload images and content.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "0.4.
|
|
8
|
+
__version__ = "0.4.6"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
10
|
__copyright__ = "Copyright 2022-2025, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
md2conf/__main__.py
CHANGED
|
@@ -14,14 +14,15 @@ import logging
|
|
|
14
14
|
import os.path
|
|
15
15
|
import sys
|
|
16
16
|
import typing
|
|
17
|
+
from io import StringIO
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any, Iterable, Literal, Optional, Sequence, Union
|
|
19
20
|
|
|
20
21
|
from . import __version__
|
|
21
22
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
23
|
+
from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
|
|
22
24
|
from .extra import override
|
|
23
25
|
from .metadata import ConfluenceSiteMetadata
|
|
24
|
-
from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class Arguments(argparse.Namespace):
|
|
@@ -68,30 +69,6 @@ class KwargsAppendAction(argparse.Action):
|
|
|
68
69
|
setattr(namespace, self.dest, d)
|
|
69
70
|
|
|
70
71
|
|
|
71
|
-
def unsupported(prefer: str) -> type[argparse.Action]:
|
|
72
|
-
class UnsupportedAction(argparse.Action):
|
|
73
|
-
"""Display an error for unsupported command-line options."""
|
|
74
|
-
|
|
75
|
-
@override
|
|
76
|
-
def __call__(
|
|
77
|
-
self,
|
|
78
|
-
parser: argparse.ArgumentParser,
|
|
79
|
-
namespace: argparse.Namespace,
|
|
80
|
-
values: Union[None, str, Sequence[Any]],
|
|
81
|
-
option_string: Optional[str] = None,
|
|
82
|
-
) -> None:
|
|
83
|
-
raise argparse.ArgumentError(
|
|
84
|
-
self,
|
|
85
|
-
f"this command-line option is no longer supported, use `--{prefer}`",
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
@override
|
|
89
|
-
def __repr__(self) -> str:
|
|
90
|
-
return f"{unsupported.__name__}({repr(prefer)})"
|
|
91
|
-
|
|
92
|
-
return UnsupportedAction
|
|
93
|
-
|
|
94
|
-
|
|
95
72
|
class PositionalOnlyHelpFormatter(argparse.HelpFormatter):
|
|
96
73
|
def _format_usage(
|
|
97
74
|
self,
|
|
@@ -112,7 +89,7 @@ class PositionalOnlyHelpFormatter(argparse.HelpFormatter):
|
|
|
112
89
|
return usage_str
|
|
113
90
|
|
|
114
91
|
|
|
115
|
-
def
|
|
92
|
+
def get_parser() -> argparse.ArgumentParser:
|
|
116
93
|
parser = argparse.ArgumentParser(formatter_class=PositionalOnlyHelpFormatter)
|
|
117
94
|
parser.prog = os.path.basename(os.path.dirname(__file__))
|
|
118
95
|
parser.add_argument("--version", action="version", version=__version__)
|
|
@@ -127,7 +104,6 @@ def main() -> None:
|
|
|
127
104
|
parser.add_argument("-u", "--username", help="Confluence user name.")
|
|
128
105
|
parser.add_argument(
|
|
129
106
|
"-a",
|
|
130
|
-
"--apikey",
|
|
131
107
|
"--api-key",
|
|
132
108
|
dest="api_key",
|
|
133
109
|
help="Confluence API key. Refer to documentation how to obtain one.",
|
|
@@ -228,12 +204,6 @@ def main() -> None:
|
|
|
228
204
|
default="png",
|
|
229
205
|
help="Format for rendering Mermaid and draw.io diagrams (default: 'png').",
|
|
230
206
|
)
|
|
231
|
-
parser.add_argument(
|
|
232
|
-
"--render-mermaid-format",
|
|
233
|
-
action=unsupported("diagram-output-format"),
|
|
234
|
-
metavar="FORMAT",
|
|
235
|
-
help="Format for rendering Mermaid diagrams (default: 'png').",
|
|
236
|
-
)
|
|
237
207
|
parser.add_argument(
|
|
238
208
|
"--heading-anchors",
|
|
239
209
|
action="store_true",
|
|
@@ -272,7 +242,18 @@ def main() -> None:
|
|
|
272
242
|
default=False,
|
|
273
243
|
help="Enable Confluence Web UI links. (Typically required for on-prem versions of Confluence.)",
|
|
274
244
|
)
|
|
245
|
+
return parser
|
|
246
|
+
|
|
275
247
|
|
|
248
|
+
def get_help() -> str:
|
|
249
|
+
parser = get_parser()
|
|
250
|
+
with StringIO() as buf:
|
|
251
|
+
parser.print_help(file=buf)
|
|
252
|
+
return buf.getvalue()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main() -> None:
|
|
256
|
+
parser = get_parser()
|
|
276
257
|
args = Arguments()
|
|
277
258
|
parser.parse_args(namespace=args)
|
|
278
259
|
|
|
@@ -316,7 +297,7 @@ def main() -> None:
|
|
|
316
297
|
from requests import HTTPError, JSONDecodeError
|
|
317
298
|
|
|
318
299
|
from .api import ConfluenceAPI
|
|
319
|
-
from .
|
|
300
|
+
from .publisher import Publisher
|
|
320
301
|
|
|
321
302
|
try:
|
|
322
303
|
properties = ConfluenceConnectionProperties(
|
|
@@ -332,7 +313,7 @@ def main() -> None:
|
|
|
332
313
|
parser.error(str(e))
|
|
333
314
|
try:
|
|
334
315
|
with ConfluenceAPI(properties) as api:
|
|
335
|
-
|
|
316
|
+
Publisher(
|
|
336
317
|
api,
|
|
337
318
|
options,
|
|
338
319
|
).process(args.mdpath)
|
md2conf/api.py
CHANGED
|
@@ -11,6 +11,8 @@ import enum
|
|
|
11
11
|
import io
|
|
12
12
|
import logging
|
|
13
13
|
import mimetypes
|
|
14
|
+
import ssl
|
|
15
|
+
import sys
|
|
14
16
|
import typing
|
|
15
17
|
from dataclasses import dataclass
|
|
16
18
|
from pathlib import Path
|
|
@@ -19,11 +21,18 @@ from typing import Any, Optional, TypeVar
|
|
|
19
21
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
20
22
|
|
|
21
23
|
import requests
|
|
24
|
+
from requests.adapters import HTTPAdapter
|
|
22
25
|
from strong_typing.core import JsonType
|
|
23
26
|
from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
|
|
24
27
|
|
|
28
|
+
from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
|
|
29
|
+
from .extra import override
|
|
25
30
|
from .metadata import ConfluenceSiteMetadata
|
|
26
|
-
|
|
31
|
+
|
|
32
|
+
if sys.version_info >= (3, 10):
|
|
33
|
+
import truststore
|
|
34
|
+
else:
|
|
35
|
+
import certifi
|
|
27
36
|
|
|
28
37
|
T = TypeVar("T")
|
|
29
38
|
|
|
@@ -336,9 +345,33 @@ class ConfluenceUpdateAttachmentRequest:
|
|
|
336
345
|
version: ConfluenceContentVersion
|
|
337
346
|
|
|
338
347
|
|
|
348
|
+
class TruststoreAdapter(HTTPAdapter):
|
|
349
|
+
"""
|
|
350
|
+
Provides a general-case interface for HTTPS sessions to connect to HTTPS URLs.
|
|
351
|
+
|
|
352
|
+
This class implements the Transport Adapter interface in the Python library `requests`.
|
|
353
|
+
|
|
354
|
+
This class will usually be created by the :class:`requests.Session` class under the covers.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
@override
|
|
358
|
+
def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any) -> None:
|
|
359
|
+
"""
|
|
360
|
+
Adapts the pool manager to use the provided SSL context instead of the default.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
if sys.version_info >= (3, 10):
|
|
364
|
+
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
365
|
+
else:
|
|
366
|
+
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
|
|
367
|
+
ctx.check_hostname = True
|
|
368
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
369
|
+
super().init_poolmanager(connections, maxsize, block, ssl_context=ctx, **pool_kwargs) # type: ignore[no-untyped-call]
|
|
370
|
+
|
|
371
|
+
|
|
339
372
|
class ConfluenceAPI:
|
|
340
373
|
"""
|
|
341
|
-
|
|
374
|
+
Encapsulates operations that can be invoked via the [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/).
|
|
342
375
|
"""
|
|
343
376
|
|
|
344
377
|
properties: ConfluenceConnectionProperties
|
|
@@ -348,7 +381,13 @@ class ConfluenceAPI:
|
|
|
348
381
|
self.properties = properties or ConfluenceConnectionProperties()
|
|
349
382
|
|
|
350
383
|
def __enter__(self) -> "ConfluenceSession":
|
|
384
|
+
"""
|
|
385
|
+
Opens a connection to a Confluence server.
|
|
386
|
+
"""
|
|
387
|
+
|
|
351
388
|
session = requests.Session()
|
|
389
|
+
session.mount("https://", TruststoreAdapter())
|
|
390
|
+
|
|
352
391
|
if self.properties.user_name:
|
|
353
392
|
session.auth = (self.properties.user_name, self.properties.api_key)
|
|
354
393
|
else:
|
|
@@ -372,6 +411,10 @@ class ConfluenceAPI:
|
|
|
372
411
|
exc_val: Optional[BaseException],
|
|
373
412
|
exc_tb: Optional[TracebackType],
|
|
374
413
|
) -> None:
|
|
414
|
+
"""
|
|
415
|
+
Closes an open connection.
|
|
416
|
+
"""
|
|
417
|
+
|
|
375
418
|
if self.session is not None:
|
|
376
419
|
self.session.close()
|
|
377
420
|
self.session = None
|
|
@@ -379,7 +422,7 @@ class ConfluenceAPI:
|
|
|
379
422
|
|
|
380
423
|
class ConfluenceSession:
|
|
381
424
|
"""
|
|
382
|
-
|
|
425
|
+
Represents an active connection to a Confluence server.
|
|
383
426
|
"""
|
|
384
427
|
|
|
385
428
|
session: requests.Session
|
|
@@ -418,8 +461,31 @@ class ConfluenceSession:
|
|
|
418
461
|
if not base_path:
|
|
419
462
|
raise ArgumentError("Confluence base path not specified and cannot be inferred")
|
|
420
463
|
self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
|
|
464
|
+
|
|
421
465
|
if not api_url:
|
|
422
|
-
|
|
466
|
+
LOGGER.info("Discovering Confluence REST API URL")
|
|
467
|
+
try:
|
|
468
|
+
# obtain cloud ID to build URL for access with scoped token
|
|
469
|
+
response = self.session.get(f"https://{self.site.domain}/_edge/tenant_info", headers={"Accept": "application/json"}, verify=True)
|
|
470
|
+
if response.text:
|
|
471
|
+
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
472
|
+
response.raise_for_status()
|
|
473
|
+
cloud_id = response.json()["cloudId"]
|
|
474
|
+
|
|
475
|
+
# try next-generation REST API URL
|
|
476
|
+
LOGGER.info("Probing scoped Confluence REST API URL")
|
|
477
|
+
self.api_url = f"https://api.atlassian.com/ex/confluence/{cloud_id}/"
|
|
478
|
+
url = self._build_url(ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"})
|
|
479
|
+
response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
|
|
480
|
+
if response.text:
|
|
481
|
+
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
482
|
+
response.raise_for_status()
|
|
483
|
+
|
|
484
|
+
LOGGER.info("Configured scoped Confluence REST API URL: %s", self.api_url)
|
|
485
|
+
except requests.exceptions.HTTPError:
|
|
486
|
+
# fall back to classic REST API URL
|
|
487
|
+
self.api_url = f"https://{self.site.domain}{self.site.base_path}"
|
|
488
|
+
LOGGER.info("Configured classic Confluence REST API URL: %s", self.api_url)
|
|
423
489
|
|
|
424
490
|
def close(self) -> None:
|
|
425
491
|
self.session.close()
|
|
@@ -454,7 +520,7 @@ class ConfluenceSession:
|
|
|
454
520
|
"Executes an HTTP request via Confluence API."
|
|
455
521
|
|
|
456
522
|
url = self._build_url(version, path, query)
|
|
457
|
-
response = self.session.get(url, headers={"Accept": "application/json"})
|
|
523
|
+
response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
|
|
458
524
|
if response.text:
|
|
459
525
|
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
460
526
|
response.raise_for_status()
|
|
@@ -466,7 +532,7 @@ class ConfluenceSession:
|
|
|
466
532
|
items: list[JsonType] = []
|
|
467
533
|
url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
|
|
468
534
|
while True:
|
|
469
|
-
response = self.session.get(url, headers={"Accept": "application/json"})
|
|
535
|
+
response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
|
|
470
536
|
response.raise_for_status()
|
|
471
537
|
|
|
472
538
|
payload = typing.cast(dict[str, JsonType], response.json())
|
|
@@ -502,22 +568,16 @@ class ConfluenceSession:
|
|
|
502
568
|
"Creates a new object via Confluence REST API."
|
|
503
569
|
|
|
504
570
|
url, headers, data = self._build_request(version, path, body, response_type)
|
|
505
|
-
response = self.session.post(
|
|
506
|
-
|
|
507
|
-
data=data,
|
|
508
|
-
headers=headers,
|
|
509
|
-
)
|
|
571
|
+
response = self.session.post(url, data=data, headers=headers, verify=True)
|
|
572
|
+
response.raise_for_status()
|
|
510
573
|
return response_cast(response_type, response)
|
|
511
574
|
|
|
512
575
|
def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T:
|
|
513
576
|
"Updates an existing object via Confluence REST API."
|
|
514
577
|
|
|
515
578
|
url, headers, data = self._build_request(version, path, body, response_type)
|
|
516
|
-
response = self.session.put(
|
|
517
|
-
|
|
518
|
-
data=data,
|
|
519
|
-
headers=headers,
|
|
520
|
-
)
|
|
579
|
+
response = self.session.put(url, data=data, headers=headers, verify=True)
|
|
580
|
+
response.raise_for_status()
|
|
521
581
|
return response_cast(response_type, response)
|
|
522
582
|
|
|
523
583
|
def space_id_to_key(self, id: str) -> str:
|
|
@@ -687,6 +747,7 @@ class ConfluenceSession:
|
|
|
687
747
|
"X-Atlassian-Token": "no-check",
|
|
688
748
|
"Accept": "application/json",
|
|
689
749
|
},
|
|
750
|
+
verify=True,
|
|
690
751
|
)
|
|
691
752
|
elif raw_data is not None:
|
|
692
753
|
LOGGER.info("Uploading raw data: %s", attachment_name)
|
|
@@ -714,6 +775,7 @@ class ConfluenceSession:
|
|
|
714
775
|
"X-Atlassian-Token": "no-check",
|
|
715
776
|
"Accept": "application/json",
|
|
716
777
|
},
|
|
778
|
+
verify=True,
|
|
717
779
|
)
|
|
718
780
|
else:
|
|
719
781
|
raise NotImplementedError("parameter match not exhaustive")
|
|
@@ -875,6 +937,7 @@ class ConfluenceSession:
|
|
|
875
937
|
"Content-Type": "application/json; charset=utf-8",
|
|
876
938
|
"Accept": "application/json",
|
|
877
939
|
},
|
|
940
|
+
verify=True,
|
|
878
941
|
)
|
|
879
942
|
response.raise_for_status()
|
|
880
943
|
return _json_to_object(ConfluencePage, response.json())
|
|
@@ -892,7 +955,7 @@ class ConfluenceSession:
|
|
|
892
955
|
# move to trash
|
|
893
956
|
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
894
957
|
LOGGER.info("Moving page to trash: %s", page_id)
|
|
895
|
-
response = self.session.delete(url)
|
|
958
|
+
response = self.session.delete(url, verify=True)
|
|
896
959
|
response.raise_for_status()
|
|
897
960
|
|
|
898
961
|
if purge:
|
|
@@ -900,7 +963,7 @@ class ConfluenceSession:
|
|
|
900
963
|
query = {"purge": "true"}
|
|
901
964
|
url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
|
|
902
965
|
LOGGER.info("Permanently deleting page: %s", page_id)
|
|
903
|
-
response = self.session.delete(url)
|
|
966
|
+
response = self.session.delete(url, verify=True)
|
|
904
967
|
response.raise_for_status()
|
|
905
968
|
|
|
906
969
|
def page_exists(
|
|
@@ -935,6 +998,7 @@ class ConfluenceSession:
|
|
|
935
998
|
"Content-Type": "application/json; charset=utf-8",
|
|
936
999
|
"Accept": "application/json",
|
|
937
1000
|
},
|
|
1001
|
+
verify=True,
|
|
938
1002
|
)
|
|
939
1003
|
response.raise_for_status()
|
|
940
1004
|
data = typing.cast(dict[str, JsonType], response.json())
|
|
@@ -999,7 +1063,7 @@ class ConfluenceSession:
|
|
|
999
1063
|
query = {"name": label.name}
|
|
1000
1064
|
|
|
1001
1065
|
url = self._build_url(ConfluenceVersion.VERSION_1, path, query)
|
|
1002
|
-
response = self.session.delete(url)
|
|
1066
|
+
response = self.session.delete(url, verify=True)
|
|
1003
1067
|
if response.text:
|
|
1004
1068
|
LOGGER.debug("Received HTTP payload:\n%s", response.text)
|
|
1005
1069
|
response.raise_for_status()
|
|
@@ -1058,7 +1122,7 @@ class ConfluenceSession:
|
|
|
1058
1122
|
|
|
1059
1123
|
path = f"/pages/{page_id}/properties/{property_id}"
|
|
1060
1124
|
url = self._build_url(ConfluenceVersion.VERSION_2, path)
|
|
1061
|
-
response = self.session.delete(url)
|
|
1125
|
+
response = self.session.delete(url, verify=True)
|
|
1062
1126
|
response.raise_for_status()
|
|
1063
1127
|
|
|
1064
1128
|
def update_content_property_for_page(
|
md2conf/converter.py
CHANGED
|
@@ -21,17 +21,20 @@ from urllib.parse import ParseResult, quote_plus, urlparse
|
|
|
21
21
|
|
|
22
22
|
import lxml.etree as ET
|
|
23
23
|
from strong_typing.core import JsonType
|
|
24
|
+
from strong_typing.exception import JsonTypeError
|
|
24
25
|
|
|
25
26
|
from . import drawio, mermaid
|
|
26
27
|
from .collection import ConfluencePageCollection
|
|
27
28
|
from .csf import AC_ATTR, AC_ELEM, HTML, RI_ATTR, RI_ELEM, ParseError, elements_from_strings, elements_to_string, normalize_inline
|
|
28
29
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
30
|
+
from .emoticon import emoji_to_emoticon
|
|
31
|
+
from .environment import PageError
|
|
29
32
|
from .extra import override, path_relative_to
|
|
30
33
|
from .latex import get_png_dimensions, remove_png_chunks, render_latex
|
|
31
34
|
from .markdown import markdown_to_html
|
|
35
|
+
from .mermaid import MermaidConfigProperties
|
|
32
36
|
from .metadata import ConfluenceSiteMetadata
|
|
33
|
-
from .
|
|
34
|
-
from .scanner import ScannedDocument, Scanner
|
|
37
|
+
from .scanner import MermaidScanner, ScannedDocument, Scanner
|
|
35
38
|
from .toc import TableOfContentsBuilder
|
|
36
39
|
from .uri import is_absolute_url, to_uuid_urn
|
|
37
40
|
from .xml import element_to_text
|
|
@@ -425,11 +428,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
425
428
|
anchor.tail = heading.text
|
|
426
429
|
heading.text = None
|
|
427
430
|
|
|
428
|
-
def
|
|
431
|
+
def _anchor_warn_or_raise(self, anchor: ET._Element, msg: str) -> None:
|
|
429
432
|
"Emit a warning or raise an exception when a path points to a resource that doesn't exist or is outside of the permitted hierarchy."
|
|
430
433
|
|
|
431
434
|
if self.options.ignore_invalid_url:
|
|
432
435
|
LOGGER.warning(msg)
|
|
436
|
+
if anchor.text:
|
|
437
|
+
anchor.text = "❌ " + anchor.text
|
|
438
|
+
elif len(anchor) > 0:
|
|
439
|
+
anchor.text = "❌ "
|
|
433
440
|
else:
|
|
434
441
|
raise DocumentError(msg)
|
|
435
442
|
|
|
@@ -478,7 +485,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
478
485
|
|
|
479
486
|
# look up the absolute path in the page metadata dictionary to discover the relative path within Confluence that should be used
|
|
480
487
|
if not is_directory_within(absolute_path, self.root_dir):
|
|
481
|
-
self.
|
|
488
|
+
self._anchor_warn_or_raise(anchor, f"relative URL {url} points to outside root path: {self.root_dir}")
|
|
482
489
|
return None
|
|
483
490
|
|
|
484
491
|
if absolute_path.suffix == ".md":
|
|
@@ -493,7 +500,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
493
500
|
|
|
494
501
|
link_metadata = self.page_metadata.get(absolute_path)
|
|
495
502
|
if link_metadata is None:
|
|
496
|
-
self.
|
|
503
|
+
self._anchor_warn_or_raise(anchor, f"unable to find matching page for URL: {relative_url.geturl()}")
|
|
497
504
|
return None
|
|
498
505
|
|
|
499
506
|
relative_path = os.path.relpath(absolute_path, self.base_dir)
|
|
@@ -529,7 +536,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
529
536
|
"""
|
|
530
537
|
|
|
531
538
|
if not absolute_path.exists():
|
|
532
|
-
self.
|
|
539
|
+
self._anchor_warn_or_raise(anchor, f"relative URL points to non-existing file: {absolute_path}")
|
|
533
540
|
return None
|
|
534
541
|
|
|
535
542
|
file_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
@@ -603,7 +610,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
603
610
|
|
|
604
611
|
absolute_path = self._verify_image_path(path)
|
|
605
612
|
if absolute_path is None:
|
|
606
|
-
return self._create_missing(path, attrs
|
|
613
|
+
return self._create_missing(path, attrs)
|
|
607
614
|
|
|
608
615
|
if absolute_path.name.endswith(".drawio.png") or absolute_path.name.endswith(".drawio.svg"):
|
|
609
616
|
return self._transform_drawio_image(absolute_path, attrs)
|
|
@@ -630,6 +637,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
630
637
|
|
|
631
638
|
return AC_ELEM("image", attrs.as_dict(), *elements)
|
|
632
639
|
|
|
640
|
+
def _warn_or_raise(self, msg: str) -> None:
|
|
641
|
+
"Emit a warning or raise an exception when a path points to a resource that doesn't exist or is outside of the permitted hierarchy."
|
|
642
|
+
|
|
643
|
+
if self.options.ignore_invalid_url:
|
|
644
|
+
LOGGER.warning(msg)
|
|
645
|
+
else:
|
|
646
|
+
raise DocumentError(msg)
|
|
647
|
+
|
|
633
648
|
def _verify_image_path(self, path: Path) -> Optional[Path]:
|
|
634
649
|
"Checks whether an image path is safe to use."
|
|
635
650
|
|
|
@@ -749,30 +764,33 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
749
764
|
*parameters,
|
|
750
765
|
)
|
|
751
766
|
|
|
752
|
-
def _create_missing(self, path: Path,
|
|
767
|
+
def _create_missing(self, path: Path, attrs: ImageAttributes) -> ET._Element:
|
|
753
768
|
"A warning panel for a missing image."
|
|
754
769
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
770
|
+
if attrs.context is FormattingContext.BLOCK:
|
|
771
|
+
message = HTML.p("❌ Missing image: ", HTML.code(path.as_posix()))
|
|
772
|
+
if attrs.caption is not None:
|
|
773
|
+
content = [
|
|
774
|
+
AC_ELEM(
|
|
775
|
+
"parameter",
|
|
776
|
+
{AC_ATTR("name"): "title"},
|
|
777
|
+
attrs.caption,
|
|
778
|
+
),
|
|
779
|
+
AC_ELEM("rich-text-body", {}, message),
|
|
780
|
+
]
|
|
781
|
+
else:
|
|
782
|
+
content = [AC_ELEM("rich-text-body", {}, message)]
|
|
767
783
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
784
|
+
return AC_ELEM(
|
|
785
|
+
"structured-macro",
|
|
786
|
+
{
|
|
787
|
+
AC_ATTR("name"): "warning",
|
|
788
|
+
AC_ATTR("schema-version"): "1",
|
|
789
|
+
},
|
|
790
|
+
*content,
|
|
791
|
+
)
|
|
792
|
+
else:
|
|
793
|
+
return HTML.span({"style": "color: rgb(255,86,48);"}, "❌ ", HTML.code(path.as_posix()))
|
|
776
794
|
|
|
777
795
|
def _transform_code_block(self, code: ET._Element) -> ET._Element:
|
|
778
796
|
"Transforms a code block."
|
|
@@ -811,6 +829,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
811
829
|
AC_ELEM("plain-text-body", ET.CDATA(content)),
|
|
812
830
|
)
|
|
813
831
|
|
|
832
|
+
def _extract_mermaid_config(self, content: str) -> Optional[MermaidConfigProperties]:
|
|
833
|
+
"""Extract scale from Mermaid YAML front matter configuration."""
|
|
834
|
+
try:
|
|
835
|
+
properties = MermaidScanner().read(content)
|
|
836
|
+
return properties.config
|
|
837
|
+
except JsonTypeError as ex:
|
|
838
|
+
LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
|
|
839
|
+
return None
|
|
840
|
+
|
|
814
841
|
def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
|
|
815
842
|
"Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
|
|
816
843
|
|
|
@@ -821,7 +848,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
821
848
|
if self.options.render_mermaid:
|
|
822
849
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
823
850
|
content = f.read()
|
|
824
|
-
|
|
851
|
+
config = self._extract_mermaid_config(content)
|
|
852
|
+
image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
|
|
825
853
|
image_filename = attachment_name(relative_path.with_suffix(f".{self.options.diagram_output_format}"))
|
|
826
854
|
self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
|
|
827
855
|
return self._create_attached_image(image_filename, attrs)
|
|
@@ -834,7 +862,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
834
862
|
"Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
|
|
835
863
|
|
|
836
864
|
if self.options.render_mermaid:
|
|
837
|
-
|
|
865
|
+
config = self._extract_mermaid_config(content)
|
|
866
|
+
image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
|
|
838
867
|
image_hash = hashlib.md5(image_data).hexdigest()
|
|
839
868
|
image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
|
|
840
869
|
self.embedded_files[image_filename] = EmbeddedFileData(image_data)
|
|
@@ -1057,7 +1086,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1057
1086
|
AC_ELEM("rich-text-body", {}, *list(blockquote)),
|
|
1058
1087
|
)
|
|
1059
1088
|
|
|
1060
|
-
def
|
|
1089
|
+
def _transform_collapsed(self, details: ET._Element) -> ET._Element:
|
|
1061
1090
|
"""
|
|
1062
1091
|
Creates a collapsed section.
|
|
1063
1092
|
|
|
@@ -1115,11 +1144,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1115
1144
|
unicode = elem.get("data-unicode", None)
|
|
1116
1145
|
alt = elem.text or ""
|
|
1117
1146
|
|
|
1147
|
+
# emoji with a matching emoticon:
|
|
1118
1148
|
# <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="😉"/>
|
|
1149
|
+
#
|
|
1150
|
+
# emoji without a corresponding emoticon:
|
|
1151
|
+
# <ac:emoticon ac:name="blue-star" ac:emoji-shortname=":shield:" ac:emoji-id="1f6e1" ac:emoji-fallback="🛡"/>
|
|
1119
1152
|
return AC_ELEM(
|
|
1120
1153
|
"emoticon",
|
|
1121
1154
|
{
|
|
1122
|
-
AC_ATTR("name"): shortname,
|
|
1155
|
+
AC_ATTR("name"): emoji_to_emoticon(shortname),
|
|
1123
1156
|
AC_ATTR("emoji-shortname"): f":{shortname}:",
|
|
1124
1157
|
AC_ATTR("emoji-id"): unicode,
|
|
1125
1158
|
AC_ATTR("emoji-fallback"): alt,
|
|
@@ -1240,7 +1273,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1240
1273
|
Transforms a footnote reference.
|
|
1241
1274
|
|
|
1242
1275
|
```
|
|
1243
|
-
<sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">
|
|
1276
|
+
<sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">REF</a></sup>
|
|
1244
1277
|
```
|
|
1245
1278
|
"""
|
|
1246
1279
|
|
|
@@ -1498,7 +1531,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1498
1531
|
# ...
|
|
1499
1532
|
# </details>
|
|
1500
1533
|
elif child.tag == "details" and len(child) > 1 and child[0].tag == "summary":
|
|
1501
|
-
return self.
|
|
1534
|
+
return self._transform_collapsed(child)
|
|
1502
1535
|
|
|
1503
1536
|
# <ol>...</ol>
|
|
1504
1537
|
elif child.tag == "ol":
|
|
@@ -1526,6 +1559,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1526
1559
|
|
|
1527
1560
|
# <table>...</table>
|
|
1528
1561
|
elif child.tag == "table":
|
|
1562
|
+
for td in child.iterdescendants("td", "th"):
|
|
1563
|
+
normalize_inline(td)
|
|
1529
1564
|
child.set("data-layout", "default")
|
|
1530
1565
|
return None
|
|
1531
1566
|
|
md2conf/csf.py
CHANGED
|
@@ -152,7 +152,7 @@ def elements_to_string(root: ET._Element) -> str:
|
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
def is_block_like(elem: ET._Element) -> bool:
|
|
155
|
-
return elem.tag in ["div", "li", "ol", "p", "pre", "ul"]
|
|
155
|
+
return elem.tag in ["div", "li", "ol", "p", "pre", "td", "th", "ul"]
|
|
156
156
|
|
|
157
157
|
|
|
158
158
|
def normalize_inline(elem: ET._Element) -> None:
|
md2conf/emoticon.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2025, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
_EMOJI_TO_EMOTICON = {
|
|
10
|
+
"grinning": "laugh",
|
|
11
|
+
"heart": "heart",
|
|
12
|
+
"slight_frown": "sad",
|
|
13
|
+
"slight_smile": "smile",
|
|
14
|
+
"stuck_out_tongue": "cheeky",
|
|
15
|
+
"thumbsdown": "thumbs-down",
|
|
16
|
+
"thumbsup": "thumbs-up",
|
|
17
|
+
"wink": "wink",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emoji_to_emoticon(shortname: str) -> str:
|
|
22
|
+
return _EMOJI_TO_EMOTICON.get(shortname) or "blue-star"
|
md2conf/mermaid.py
CHANGED
|
@@ -11,11 +11,23 @@ import os
|
|
|
11
11
|
import os.path
|
|
12
12
|
import shutil
|
|
13
13
|
import subprocess
|
|
14
|
-
from
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, Optional
|
|
15
16
|
|
|
16
17
|
LOGGER = logging.getLogger(__name__)
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
@dataclass
|
|
21
|
+
class MermaidConfigProperties:
|
|
22
|
+
"""
|
|
23
|
+
Configuration options for rendering Mermaid diagrams.
|
|
24
|
+
|
|
25
|
+
:param scale: Scaling factor for the rendered diagram.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
scale: Optional[float] = None
|
|
29
|
+
|
|
30
|
+
|
|
19
31
|
def is_docker() -> bool:
|
|
20
32
|
"True if the application is running in a Docker container."
|
|
21
33
|
|
|
@@ -44,9 +56,12 @@ def has_mmdc() -> bool:
|
|
|
44
56
|
return shutil.which(executable) is not None
|
|
45
57
|
|
|
46
58
|
|
|
47
|
-
def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
|
|
59
|
+
def render_diagram(source: str, output_format: Literal["png", "svg"] = "png", config: Optional[MermaidConfigProperties] = None) -> bytes:
|
|
48
60
|
"Generates a PNG or SVG image from a Mermaid diagram source."
|
|
49
61
|
|
|
62
|
+
if config is None:
|
|
63
|
+
config = MermaidConfigProperties()
|
|
64
|
+
|
|
50
65
|
cmd = [
|
|
51
66
|
get_mmdc(),
|
|
52
67
|
"--input",
|
|
@@ -58,7 +73,7 @@ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") ->
|
|
|
58
73
|
"--backgroundColor",
|
|
59
74
|
"transparent",
|
|
60
75
|
"--scale",
|
|
61
|
-
|
|
76
|
+
str(config.scale or 2),
|
|
62
77
|
]
|
|
63
78
|
root = os.path.dirname(__file__)
|
|
64
79
|
if is_docker():
|
md2conf/processor.py
CHANGED
|
@@ -16,9 +16,9 @@ from typing import Iterable, Optional
|
|
|
16
16
|
from .collection import ConfluencePageCollection
|
|
17
17
|
from .converter import ConfluenceDocument
|
|
18
18
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
19
|
+
from .environment import ArgumentError
|
|
19
20
|
from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
|
|
20
21
|
from .metadata import ConfluenceSiteMetadata
|
|
21
|
-
from .properties import ArgumentError
|
|
22
22
|
from .scanner import Scanner
|
|
23
23
|
|
|
24
24
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -14,10 +14,10 @@ from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession,
|
|
|
14
14
|
from .converter import ConfluenceDocument, attachment_name, get_volatile_attributes, get_volatile_elements
|
|
15
15
|
from .csf import AC_ATTR, elements_from_string
|
|
16
16
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
17
|
+
from .environment import PageError
|
|
17
18
|
from .extra import override, path_relative_to
|
|
18
19
|
from .metadata import ConfluencePageMetadata
|
|
19
20
|
from .processor import Converter, DocumentNode, Processor, ProcessorFactory
|
|
20
|
-
from .properties import PageError
|
|
21
21
|
from .xml import is_xml_equal, unwrap_substitute
|
|
22
22
|
|
|
23
23
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -73,20 +73,23 @@ class SynchronizingProcessor(Processor):
|
|
|
73
73
|
# verify if page exists
|
|
74
74
|
page = self.api.get_page_properties(node.page_id)
|
|
75
75
|
update = False
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
else:
|
|
77
|
+
if node.title is not None:
|
|
78
|
+
# use title extracted from source metadata
|
|
79
|
+
title = node.title
|
|
80
|
+
else:
|
|
81
|
+
# assign an auto-generated title
|
|
82
|
+
digest = self._generate_hash(node.absolute_path)
|
|
83
|
+
title = f"{node.absolute_path.stem} [{digest}]"
|
|
84
|
+
|
|
85
|
+
# look up page by (possibly auto-generated) title
|
|
86
|
+
page = self.api.get_or_create_page(title, parent_id.page_id)
|
|
79
87
|
|
|
80
88
|
if page.status is ConfluenceStatus.ARCHIVED:
|
|
89
|
+
# user has archived a page with this (auto-generated) title
|
|
81
90
|
raise PageError(f"unable to update archived page with ID {page.id}")
|
|
82
91
|
|
|
83
92
|
update = True
|
|
84
|
-
else:
|
|
85
|
-
# always create a new page
|
|
86
|
-
digest = self._generate_hash(node.absolute_path)
|
|
87
|
-
title = f"{node.absolute_path.stem} [{digest}]"
|
|
88
|
-
page = self.api.create_page(parent_id.page_id, title, "")
|
|
89
|
-
update = True
|
|
90
93
|
|
|
91
94
|
space_key = self.api.space_id_to_key(page.spaceId)
|
|
92
95
|
if update:
|
|
@@ -215,7 +218,7 @@ class SynchronizingProcessorFactory(ProcessorFactory):
|
|
|
215
218
|
return SynchronizingProcessor(self.api, self.options, root_dir)
|
|
216
219
|
|
|
217
220
|
|
|
218
|
-
class
|
|
221
|
+
class Publisher(Converter):
|
|
219
222
|
"""
|
|
220
223
|
The entry point for Markdown to Confluence conversion.
|
|
221
224
|
|
md2conf/scanner.py
CHANGED
|
@@ -15,6 +15,8 @@ import yaml
|
|
|
15
15
|
from strong_typing.core import JsonType
|
|
16
16
|
from strong_typing.serialization import DeserializerOptions, json_to_object
|
|
17
17
|
|
|
18
|
+
from .mermaid import MermaidConfigProperties
|
|
19
|
+
|
|
18
20
|
T = TypeVar("T")
|
|
19
21
|
|
|
20
22
|
|
|
@@ -155,3 +157,47 @@ class Scanner:
|
|
|
155
157
|
properties=properties,
|
|
156
158
|
text=text,
|
|
157
159
|
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class MermaidProperties:
|
|
164
|
+
"""
|
|
165
|
+
An object that holds the front-matter properties structure for Mermaid diagrams.
|
|
166
|
+
|
|
167
|
+
:param title: The title of the diagram.
|
|
168
|
+
:param config: Configuration options for rendering.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
title: Optional[str] = None
|
|
172
|
+
config: Optional[MermaidConfigProperties] = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class MermaidScanner:
|
|
176
|
+
"""
|
|
177
|
+
Extracts properties from the JSON/YAML front-matter of a Mermaid diagram.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def read(self, content: str) -> MermaidProperties:
|
|
181
|
+
"""
|
|
182
|
+
Extracts rendering preferences from a Mermaid front-matter content.
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
---
|
|
186
|
+
title: Tiny flow diagram
|
|
187
|
+
config:
|
|
188
|
+
scale: 1
|
|
189
|
+
---
|
|
190
|
+
flowchart LR
|
|
191
|
+
A[Component A] --> B[Component B]
|
|
192
|
+
B --> C[Component C]
|
|
193
|
+
```
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
properties, text = extract_frontmatter_properties(content)
|
|
197
|
+
if properties is not None:
|
|
198
|
+
front_matter = _json_to_object(MermaidProperties, properties)
|
|
199
|
+
config = front_matter.config or MermaidConfigProperties()
|
|
200
|
+
|
|
201
|
+
return MermaidProperties(title=front_matter.title, config=config)
|
|
202
|
+
|
|
203
|
+
return MermaidProperties()
|
|
File without changes
|
{markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.6.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|