markdown-to-confluence 0.2.2__py3-none-any.whl → 0.2.4__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.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/METADATA +11 -3
- markdown_to_confluence-0.2.4.dist-info/RECORD +21 -0
- {markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/WHEEL +1 -1
- md2conf/__init__.py +1 -1
- md2conf/api.py +1 -20
- md2conf/application.py +18 -5
- md2conf/converter.py +124 -20
- md2conf/emoji.py +48 -0
- md2conf/matcher.py +34 -10
- md2conf/mermaid.py +2 -3
- md2conf/processor.py +3 -3
- md2conf/util.py +19 -0
- markdown_to_confluence-0.2.2.dist-info/RECORD +0 -19
- {markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/LICENSE +0 -0
- {markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/zip-safe +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.4
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
@@ -26,6 +26,8 @@ Requires-Dist: types-lxml>=2024.8.7
|
|
|
26
26
|
Requires-Dist: markdown>=3.6
|
|
27
27
|
Requires-Dist: types-markdown>=3.6
|
|
28
28
|
Requires-Dist: pymdown-extensions>=10.9
|
|
29
|
+
Requires-Dist: pyyaml>=6.0
|
|
30
|
+
Requires-Dist: types-PyYAML>=6.0
|
|
29
31
|
Requires-Dist: requests>=2.32
|
|
30
32
|
Requires-Dist: types-requests>=2.32
|
|
31
33
|
|
|
@@ -144,6 +146,12 @@ Provide generated-by prompt text in the Markdown file with a tag:
|
|
|
144
146
|
|
|
145
147
|
Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes precedence.
|
|
146
148
|
|
|
149
|
+
### Publishing a directory
|
|
150
|
+
|
|
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 `mdpath`. This will traverse the specified directory recursively, and synchronize each Markdown file.
|
|
152
|
+
|
|
153
|
+
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
|
+
|
|
147
155
|
### Ignoring files
|
|
148
156
|
|
|
149
157
|
Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
|
|
@@ -208,13 +216,13 @@ You can run the Docker container via `docker run` or via `Dockerfile`. Either ca
|
|
|
208
216
|
With `docker run`, you can pass Confluence domain, user, API and space key directly to `docker run`:
|
|
209
217
|
|
|
210
218
|
```sh
|
|
211
|
-
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf -d instructure.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s DAP ./
|
|
219
|
+
docker run --rm --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest -d instructure.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s DAP ./
|
|
212
220
|
```
|
|
213
221
|
|
|
214
222
|
Alternatively, you can use a separate file `.env` to pass these parameters as environment variables:
|
|
215
223
|
|
|
216
224
|
```sh
|
|
217
|
-
docker run --rm --env-file .env --name md2conf -v $(pwd):/data leventehunyadi/md2conf ./
|
|
225
|
+
docker run --rm --env-file .env --name md2conf -v $(pwd):/data leventehunyadi/md2conf:latest ./
|
|
218
226
|
```
|
|
219
227
|
|
|
220
228
|
In each case, `-v $(pwd):/data` maps the current directory to Docker container's `WORKDIR` such *md2conf* can scan files and directories in the local file system.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
md2conf/__init__.py,sha256=zCKYQvETObXjgxGYFlwiftPJL64cqwfEW3PGriejyh4,402
|
|
2
|
+
md2conf/__main__.py,sha256=_qUspNQmQdhpH4Myh9vXDcauPyUx_FyEzNtaW_c8ytY,6601
|
|
3
|
+
md2conf/api.py,sha256=bP3Kp4PsGQrPyQMOs-MwE2Znl1ewuKNslMCv7AtXIT0,16366
|
|
4
|
+
md2conf/application.py,sha256=f5O-EUTXh-SO4P57rgqfwBbbX-A8S_n7PM4HW9AsMLc,8277
|
|
5
|
+
md2conf/converter.py,sha256=H9OdaLb_JXAYIa5eEwVmJN75ESWarplq2LRo30gWur4,34271
|
|
6
|
+
md2conf/emoji.py,sha256=2vMZlLD4m2X6MB-Fjv_GDzEUelb_sg4UBtF463d_p90,1792
|
|
7
|
+
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
8
|
+
md2conf/matcher.py,sha256=bZMX_GTXuEeKqIPDES8KqAqTBiesKfSH9rwbNFkD25A,3451
|
|
9
|
+
md2conf/mermaid.py,sha256=u2pSKaLrvB3yeDciVO9mIfUT2dbVVfTALYLBaIgaJ-Y,1975
|
|
10
|
+
md2conf/processor.py,sha256=bdwSEnxuvWsZd34_KcvLqigM8GHnll9fc-hf1VQ_5aI,4010
|
|
11
|
+
md2conf/properties.py,sha256=2l1tW8HmnrEsXN4-Dtby2tYJQTG1MirRpM3H6ykjQ4c,1858
|
|
12
|
+
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
13
|
+
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
md2conf/util.py,sha256=mghtBv5c0vOBHi5CxjBh4LZbjQ0Cu0h_vB30RN4N8Bk,611
|
|
15
|
+
markdown_to_confluence-0.2.4.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
16
|
+
markdown_to_confluence-0.2.4.dist-info/METADATA,sha256=vjvF6LP_5tQAKY9ACKkmAllQDlqjyJuSg_W7bBKct5Y,12764
|
|
17
|
+
markdown_to_confluence-0.2.4.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
|
|
18
|
+
markdown_to_confluence-0.2.4.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
19
|
+
markdown_to_confluence-0.2.4.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
20
|
+
markdown_to_confluence-0.2.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
21
|
+
markdown_to_confluence-0.2.4.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.2.
|
|
8
|
+
__version__ = "0.2.4"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
10
|
__copyright__ = "Copyright 2022-2024, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
md2conf/api.py
CHANGED
|
@@ -2,7 +2,6 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import mimetypes
|
|
5
|
-
import sys
|
|
6
5
|
import typing
|
|
7
6
|
from contextlib import contextmanager
|
|
8
7
|
from dataclasses import dataclass
|
|
@@ -15,6 +14,7 @@ import requests
|
|
|
15
14
|
|
|
16
15
|
from .converter import ParseError, sanitize_confluence
|
|
17
16
|
from .properties import ConfluenceError, ConfluenceProperties
|
|
17
|
+
from .util import removeprefix
|
|
18
18
|
|
|
19
19
|
# a JSON type with possible `null` values
|
|
20
20
|
JsonType = Union[
|
|
@@ -44,25 +44,6 @@ def build_url(base_url: str, query: Optional[Dict[str, str]] = None) -> str:
|
|
|
44
44
|
return urlunparse(url_parts)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
if sys.version_info >= (3, 9):
|
|
48
|
-
|
|
49
|
-
def removeprefix(string: str, prefix: str) -> str:
|
|
50
|
-
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
51
|
-
|
|
52
|
-
return string.removeprefix(prefix)
|
|
53
|
-
|
|
54
|
-
else:
|
|
55
|
-
|
|
56
|
-
def removeprefix(string: str, prefix: str) -> str:
|
|
57
|
-
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
58
|
-
|
|
59
|
-
if string.startswith(prefix):
|
|
60
|
-
prefix_len = len(prefix)
|
|
61
|
-
return string[prefix_len:]
|
|
62
|
-
else:
|
|
63
|
-
return string
|
|
64
|
-
|
|
65
|
-
|
|
66
47
|
LOGGER = logging.getLogger(__name__)
|
|
67
48
|
|
|
68
49
|
|
md2conf/application.py
CHANGED
|
@@ -3,6 +3,8 @@ import os.path
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Dict, List, Optional
|
|
5
5
|
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
6
8
|
from .api import ConfluencePage, ConfluenceSession
|
|
7
9
|
from .converter import (
|
|
8
10
|
ConfluenceDocument,
|
|
@@ -10,6 +12,7 @@ from .converter import (
|
|
|
10
12
|
ConfluencePageMetadata,
|
|
11
13
|
ConfluenceQualifiedID,
|
|
12
14
|
attachment_name,
|
|
15
|
+
extract_frontmatter,
|
|
13
16
|
extract_qualified_id,
|
|
14
17
|
read_qualified_id,
|
|
15
18
|
)
|
|
@@ -95,19 +98,19 @@ class Application:
|
|
|
95
98
|
files: List[Path] = []
|
|
96
99
|
directories: List[Path] = []
|
|
97
100
|
for entry in os.scandir(local_dir):
|
|
98
|
-
if matcher.is_excluded(entry.name):
|
|
101
|
+
if matcher.is_excluded(entry.name, entry.is_dir()):
|
|
99
102
|
continue
|
|
100
103
|
|
|
101
104
|
if entry.is_file():
|
|
102
|
-
files.append(
|
|
105
|
+
files.append(Path(local_dir) / entry.name)
|
|
103
106
|
elif entry.is_dir():
|
|
104
|
-
directories.append(
|
|
107
|
+
directories.append(Path(local_dir) / entry.name)
|
|
105
108
|
|
|
106
109
|
# make page act as parent node in Confluence
|
|
107
110
|
parent_id: Optional[ConfluenceQualifiedID] = None
|
|
108
|
-
if "index.md" in files:
|
|
111
|
+
if (Path(local_dir) / "index.md") in files:
|
|
109
112
|
parent_id = read_qualified_id(Path(local_dir) / "index.md")
|
|
110
|
-
elif "README.md" in files:
|
|
113
|
+
elif (Path(local_dir) / "README.md") in files:
|
|
111
114
|
parent_id = read_qualified_id(Path(local_dir) / "README.md")
|
|
112
115
|
|
|
113
116
|
if parent_id is None:
|
|
@@ -137,6 +140,8 @@ class Application:
|
|
|
137
140
|
document = f.read()
|
|
138
141
|
|
|
139
142
|
qualified_id, document = extract_qualified_id(document)
|
|
143
|
+
frontmatter, document = extract_frontmatter(document)
|
|
144
|
+
|
|
140
145
|
if qualified_id is not None:
|
|
141
146
|
confluence_page = self.api.get_page(
|
|
142
147
|
qualified_id.page_id, space_key=qualified_id.space_key
|
|
@@ -147,6 +152,14 @@ class Application:
|
|
|
147
152
|
f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
|
|
148
153
|
)
|
|
149
154
|
|
|
155
|
+
# assign title from frontmatter if present
|
|
156
|
+
if title is None and frontmatter is not None:
|
|
157
|
+
properties = yaml.safe_load(frontmatter)
|
|
158
|
+
if isinstance(properties, dict):
|
|
159
|
+
property_title = properties.get("title")
|
|
160
|
+
if isinstance(property_title, str):
|
|
161
|
+
title = property_title
|
|
162
|
+
|
|
150
163
|
confluence_page = self._create_page(
|
|
151
164
|
absolute_path, document, title, parent_id
|
|
152
165
|
)
|
md2conf/converter.py
CHANGED
|
@@ -7,9 +7,10 @@ import os.path
|
|
|
7
7
|
import re
|
|
8
8
|
import sys
|
|
9
9
|
import uuid
|
|
10
|
+
import xml.etree.ElementTree
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from typing import Dict, List, Literal, Optional, Tuple
|
|
13
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
13
14
|
from urllib.parse import ParseResult, urlparse, urlunparse
|
|
14
15
|
|
|
15
16
|
import lxml.etree as ET
|
|
@@ -55,6 +56,27 @@ def is_relative_url(url: str) -> bool:
|
|
|
55
56
|
return not bool(urlparts.scheme) and not bool(urlparts.netloc)
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
def emoji_generator(
|
|
60
|
+
index: str,
|
|
61
|
+
shortname: str,
|
|
62
|
+
alias: Optional[str],
|
|
63
|
+
uc: Optional[str],
|
|
64
|
+
alt: str,
|
|
65
|
+
title: Optional[str],
|
|
66
|
+
category: Optional[str],
|
|
67
|
+
options: Dict[str, Any],
|
|
68
|
+
md: markdown.Markdown,
|
|
69
|
+
) -> xml.etree.ElementTree.Element:
|
|
70
|
+
name = (alias or shortname).strip(":")
|
|
71
|
+
span = xml.etree.ElementTree.Element("span", {"data-emoji": name})
|
|
72
|
+
if uc is not None:
|
|
73
|
+
# convert series of Unicode code point hexadecimal values into characters
|
|
74
|
+
span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
|
|
75
|
+
else:
|
|
76
|
+
span.text = alt
|
|
77
|
+
return span
|
|
78
|
+
|
|
79
|
+
|
|
58
80
|
def markdown_to_html(content: str) -> str:
|
|
59
81
|
return markdown.markdown(
|
|
60
82
|
content,
|
|
@@ -62,11 +84,17 @@ def markdown_to_html(content: str) -> str:
|
|
|
62
84
|
"admonition",
|
|
63
85
|
"markdown.extensions.tables",
|
|
64
86
|
"markdown.extensions.fenced_code",
|
|
87
|
+
"pymdownx.emoji",
|
|
65
88
|
"pymdownx.magiclink",
|
|
66
89
|
"pymdownx.tilde",
|
|
67
90
|
"sane_lists",
|
|
68
91
|
"md_in_html",
|
|
69
92
|
],
|
|
93
|
+
extension_configs={
|
|
94
|
+
"pymdownx.emoji": {
|
|
95
|
+
"emoji_generator": emoji_generator,
|
|
96
|
+
}
|
|
97
|
+
},
|
|
70
98
|
)
|
|
71
99
|
|
|
72
100
|
|
|
@@ -81,6 +109,7 @@ def _elements_from_strings(dtd_path: Path, items: List[str]) -> ET._Element:
|
|
|
81
109
|
|
|
82
110
|
parser = ET.XMLParser(
|
|
83
111
|
remove_blank_text=True,
|
|
112
|
+
remove_comments=True,
|
|
84
113
|
strip_cdata=False,
|
|
85
114
|
load_dtd=True,
|
|
86
115
|
)
|
|
@@ -309,10 +338,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
309
338
|
anchor.tail = heading.text
|
|
310
339
|
heading.text = None
|
|
311
340
|
|
|
312
|
-
def _transform_link(self, anchor: ET._Element) ->
|
|
341
|
+
def _transform_link(self, anchor: ET._Element) -> Optional[ET._Element]:
|
|
313
342
|
url = anchor.attrib["href"]
|
|
314
343
|
if is_absolute_url(url):
|
|
315
|
-
return
|
|
344
|
+
return None
|
|
316
345
|
|
|
317
346
|
LOGGER.debug(f"found link {url} relative to {self.path}")
|
|
318
347
|
relative_url: ParseResult = urlparse(url)
|
|
@@ -325,8 +354,23 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
325
354
|
and not relative_url.query
|
|
326
355
|
):
|
|
327
356
|
LOGGER.debug(f"found local URL: {url}")
|
|
328
|
-
|
|
329
|
-
|
|
357
|
+
if self.options.heading_anchors:
|
|
358
|
+
# <ac:link ac:anchor="anchor"><ac:link-body>...</ac:link-body></ac:link>
|
|
359
|
+
target = relative_url.fragment.lstrip("#")
|
|
360
|
+
link_body = AC("link-body", {}, *list(anchor))
|
|
361
|
+
link_body.text = anchor.text
|
|
362
|
+
link_wrapper = AC(
|
|
363
|
+
"link",
|
|
364
|
+
{
|
|
365
|
+
ET.QName(namespaces["ac"], "anchor"): target,
|
|
366
|
+
},
|
|
367
|
+
link_body,
|
|
368
|
+
)
|
|
369
|
+
link_wrapper.tail = anchor.tail
|
|
370
|
+
return link_wrapper
|
|
371
|
+
else:
|
|
372
|
+
anchor.attrib["href"] = url
|
|
373
|
+
return None
|
|
330
374
|
|
|
331
375
|
# convert the relative URL to absolute URL based on the base path value, then look up
|
|
332
376
|
# the absolute path in the page metadata dictionary to discover the relative path
|
|
@@ -337,7 +381,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
337
381
|
if self.options.ignore_invalid_url:
|
|
338
382
|
LOGGER.warning(msg)
|
|
339
383
|
anchor.attrib.pop("href")
|
|
340
|
-
return
|
|
384
|
+
return None
|
|
341
385
|
else:
|
|
342
386
|
raise DocumentError(msg)
|
|
343
387
|
|
|
@@ -349,7 +393,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
349
393
|
if self.options.ignore_invalid_url:
|
|
350
394
|
LOGGER.warning(msg)
|
|
351
395
|
anchor.attrib.pop("href")
|
|
352
|
-
return
|
|
396
|
+
return None
|
|
353
397
|
else:
|
|
354
398
|
raise DocumentError(msg)
|
|
355
399
|
|
|
@@ -375,6 +419,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
375
419
|
|
|
376
420
|
LOGGER.debug(f"transformed relative URL: {url} to URL: {transformed_url}")
|
|
377
421
|
anchor.attrib["href"] = transformed_url
|
|
422
|
+
return None
|
|
378
423
|
|
|
379
424
|
def _transform_image(self, image: ET._Element) -> ET._Element:
|
|
380
425
|
path: str = image.attrib["src"]
|
|
@@ -678,6 +723,23 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
678
723
|
AC("rich-text-body", {}, *list(elem)),
|
|
679
724
|
)
|
|
680
725
|
|
|
726
|
+
def _transform_emoji(self, elem: ET._Element) -> ET._Element:
|
|
727
|
+
shortname = elem.attrib.get("data-emoji", "")
|
|
728
|
+
alt = elem.text or ""
|
|
729
|
+
|
|
730
|
+
# <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="😉"/>
|
|
731
|
+
# <ac:emoticon ac:name="blue-star" ac:emoji-shortname=":heavy_plus_sign:" ac:emoji-id="2795" ac:emoji-fallback="➕"/>
|
|
732
|
+
# <ac:emoticon ac:name="blue-star" ac:emoji-shortname=":heavy_minus_sign:" ac:emoji-id="2796" ac:emoji-fallback="➖"/>
|
|
733
|
+
return AC(
|
|
734
|
+
"emoticon",
|
|
735
|
+
{
|
|
736
|
+
# use "blue-star" as a placeholder name to ensure wiki page loads in timely manner
|
|
737
|
+
ET.QName(namespaces["ac"], "name"): "blue-star",
|
|
738
|
+
ET.QName(namespaces["ac"], "emoji-shortname"): f":{shortname}:",
|
|
739
|
+
ET.QName(namespaces["ac"], "emoji-fallback"): alt,
|
|
740
|
+
},
|
|
741
|
+
)
|
|
742
|
+
|
|
681
743
|
def transform(self, child: ET._Element) -> Optional[ET._Element]:
|
|
682
744
|
# normalize line breaks to regular space in element text
|
|
683
745
|
if child.text:
|
|
@@ -757,13 +819,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
757
819
|
|
|
758
820
|
# <a href="..."> ... </a>
|
|
759
821
|
elif child.tag == "a":
|
|
760
|
-
self._transform_link(child)
|
|
761
|
-
return None
|
|
822
|
+
return self._transform_link(child)
|
|
762
823
|
|
|
763
824
|
# <pre><code class="language-java"> ... </code></pre>
|
|
764
825
|
elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
|
|
765
826
|
return self._transform_block(child[0])
|
|
766
827
|
|
|
828
|
+
elif child.tag == "span" and child.attrib.has_key("data-emoji"):
|
|
829
|
+
return self._transform_emoji(child)
|
|
830
|
+
|
|
767
831
|
return None
|
|
768
832
|
|
|
769
833
|
|
|
@@ -780,16 +844,16 @@ class DocumentError(RuntimeError):
|
|
|
780
844
|
pass
|
|
781
845
|
|
|
782
846
|
|
|
783
|
-
def extract_value(pattern: str,
|
|
847
|
+
def extract_value(pattern: str, text: str) -> Tuple[Optional[str], str]:
|
|
784
848
|
values: List[str] = []
|
|
785
849
|
|
|
786
850
|
def _repl_func(matchobj: re.Match) -> str:
|
|
787
851
|
values.append(matchobj.group(1))
|
|
788
852
|
return ""
|
|
789
853
|
|
|
790
|
-
|
|
854
|
+
text = re.sub(pattern, _repl_func, text, 1, re.ASCII)
|
|
791
855
|
value = values[0] if values else None
|
|
792
|
-
return value,
|
|
856
|
+
return value, text
|
|
793
857
|
|
|
794
858
|
|
|
795
859
|
@dataclass
|
|
@@ -802,20 +866,24 @@ class ConfluenceQualifiedID:
|
|
|
802
866
|
self.space_key = space_key
|
|
803
867
|
|
|
804
868
|
|
|
805
|
-
def extract_qualified_id(
|
|
869
|
+
def extract_qualified_id(text: str) -> Tuple[Optional[ConfluenceQualifiedID], str]:
|
|
806
870
|
"Extracts the Confluence page ID and space key from a Markdown document."
|
|
807
871
|
|
|
808
|
-
page_id,
|
|
872
|
+
page_id, text = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", text)
|
|
809
873
|
|
|
810
874
|
if page_id is None:
|
|
811
|
-
return None,
|
|
875
|
+
return None, text
|
|
812
876
|
|
|
813
877
|
# extract Confluence space key
|
|
814
|
-
space_key,
|
|
815
|
-
|
|
816
|
-
)
|
|
878
|
+
space_key, text = extract_value(r"<!--\s+confluence-space-key:\s*(\S+)\s+-->", text)
|
|
879
|
+
|
|
880
|
+
return ConfluenceQualifiedID(page_id, space_key), text
|
|
881
|
+
|
|
817
882
|
|
|
818
|
-
|
|
883
|
+
def extract_frontmatter(text: str) -> Tuple[Optional[str], str]:
|
|
884
|
+
"Extracts the front matter from a Markdown document."
|
|
885
|
+
|
|
886
|
+
return extract_value(r"(?ms)\A---$(.+?)^---$", text)
|
|
819
887
|
|
|
820
888
|
|
|
821
889
|
def read_qualified_id(absolute_path: Path) -> Optional[ConfluenceQualifiedID]:
|
|
@@ -892,7 +960,7 @@ class ConfluenceDocument:
|
|
|
892
960
|
)
|
|
893
961
|
|
|
894
962
|
# extract frontmatter
|
|
895
|
-
frontmatter, text =
|
|
963
|
+
frontmatter, text = extract_frontmatter(text)
|
|
896
964
|
|
|
897
965
|
# convert to HTML
|
|
898
966
|
html = markdown_to_html(text)
|
|
@@ -963,3 +1031,39 @@ def elements_to_string(root: ET._Element) -> str:
|
|
|
963
1031
|
return m.group(1)
|
|
964
1032
|
else:
|
|
965
1033
|
raise ValueError("expected: Confluence content")
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _content_to_string(dtd_path: Path, content: str) -> str:
|
|
1037
|
+
parser = ET.XMLParser(
|
|
1038
|
+
remove_blank_text=True,
|
|
1039
|
+
remove_comments=True,
|
|
1040
|
+
strip_cdata=False,
|
|
1041
|
+
load_dtd=True,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
ns_attr_list = "".join(
|
|
1045
|
+
f' xmlns:{key}="{value}"' for key, value in namespaces.items()
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
data = [
|
|
1049
|
+
'<?xml version="1.0"?>',
|
|
1050
|
+
f'<!DOCTYPE ac:confluence PUBLIC "-//Atlassian//Confluence 4 Page//EN" "{dtd_path}">'
|
|
1051
|
+
f"<root{ns_attr_list}>",
|
|
1052
|
+
]
|
|
1053
|
+
data.append(content)
|
|
1054
|
+
data.append("</root>")
|
|
1055
|
+
|
|
1056
|
+
tree = ET.fromstringlist(data, parser=parser)
|
|
1057
|
+
return ET.tostring(tree, pretty_print=True).decode("utf-8")
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def content_to_string(content: str) -> str:
|
|
1061
|
+
"Converts a Confluence Storage Format document returned by the API into a readable XML document."
|
|
1062
|
+
|
|
1063
|
+
if sys.version_info >= (3, 9):
|
|
1064
|
+
resource_path = resources.files(__package__).joinpath("entities.dtd")
|
|
1065
|
+
with resources.as_file(resource_path) as dtd_path:
|
|
1066
|
+
return _content_to_string(dtd_path, content)
|
|
1067
|
+
else:
|
|
1068
|
+
with resources.path(__package__, "entities.dtd") as dtd_path:
|
|
1069
|
+
return _content_to_string(dtd_path, content)
|
md2conf/emoji.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
import pymdownx.emoji1_db as emoji_db
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_source(path: pathlib.Path) -> None:
|
|
7
|
+
"Generates a source Markdown document for testing emojis."
|
|
8
|
+
|
|
9
|
+
emojis = emoji_db.emoji
|
|
10
|
+
|
|
11
|
+
with open(path, "w") as f:
|
|
12
|
+
print("<!-- confluence-page-id: 86918529216 -->", file=f)
|
|
13
|
+
print("<!-- This file has been generated by a script. -->", file=f)
|
|
14
|
+
print(file=f)
|
|
15
|
+
print("## Emoji", file=f)
|
|
16
|
+
print(file=f)
|
|
17
|
+
print("| Icon | Emoji code |", file=f)
|
|
18
|
+
print("| ---- | ---------- |", file=f)
|
|
19
|
+
for key in emojis.keys():
|
|
20
|
+
key = key.strip(":")
|
|
21
|
+
print(f"| :{key}: | `:{key}:` |", file=f)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_target(path: pathlib.Path) -> None:
|
|
25
|
+
"Generates a target Confluence Storage Format (XML) document for testing emojis."
|
|
26
|
+
|
|
27
|
+
emojis = emoji_db.emoji
|
|
28
|
+
|
|
29
|
+
with open(path, "w") as f:
|
|
30
|
+
print('<ac:structured-macro ac:name="info" ac:schema-version="1">', file=f)
|
|
31
|
+
print("<ac:rich-text-body>", file=f)
|
|
32
|
+
print("<p>This page has been generated with a tool.</p>", file=f)
|
|
33
|
+
print("</ac:rich-text-body>", file=f)
|
|
34
|
+
print("</ac:structured-macro>", file=f)
|
|
35
|
+
print("<h2>Emoji</h2>", file=f)
|
|
36
|
+
print("<table>", file=f)
|
|
37
|
+
print("<thead><tr><th>Icon</th><th>Emoji code</th></tr></thead>", file=f)
|
|
38
|
+
print("<tbody>", file=f)
|
|
39
|
+
for key, data in emojis.items():
|
|
40
|
+
key = key.strip(":")
|
|
41
|
+
unicode = "".join(f"&#x{item};" for item in data["unicode"].split("-"))
|
|
42
|
+
|
|
43
|
+
print(
|
|
44
|
+
f'<tr><td><ac:emoticon ac:name="blue-star" ac:emoji-shortname=":{key}:" ac:emoji-fallback="{unicode}"/></td><td><code>:{key}:</code></td></tr>',
|
|
45
|
+
file=f,
|
|
46
|
+
)
|
|
47
|
+
print("</tbody>", file=f)
|
|
48
|
+
print("</table>", file=f)
|
md2conf/matcher.py
CHANGED
|
@@ -5,6 +5,14 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Iterable, List, Optional
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
@dataclass
|
|
9
|
+
class Entry:
|
|
10
|
+
"Represents a file or directory entry."
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
is_dir: bool
|
|
14
|
+
|
|
15
|
+
|
|
8
16
|
@dataclass
|
|
9
17
|
class MatcherOptions:
|
|
10
18
|
"""
|
|
@@ -42,13 +50,21 @@ class Matcher:
|
|
|
42
50
|
|
|
43
51
|
return self.options.extension is None or name.endswith(self.options.extension)
|
|
44
52
|
|
|
45
|
-
def is_excluded(self, name: str) -> bool:
|
|
46
|
-
"
|
|
53
|
+
def is_excluded(self, name: str, is_dir: bool) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
True if the file or directory name matches any of the exclusion patterns.
|
|
56
|
+
|
|
57
|
+
:param name: Name to match against the rule-set.
|
|
58
|
+
:param is_dir: Whether the name identifies a directory.
|
|
59
|
+
:returns: True if the name matches at least one of the exclusion patterns.
|
|
60
|
+
"""
|
|
47
61
|
|
|
62
|
+
# skip hidden files and directories
|
|
48
63
|
if name.startswith("."):
|
|
49
64
|
return True
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
# match extension for regular files
|
|
67
|
+
if not is_dir and not self.extension_matches(name):
|
|
52
68
|
return True
|
|
53
69
|
|
|
54
70
|
for rule in self.rules:
|
|
@@ -57,12 +73,18 @@ class Matcher:
|
|
|
57
73
|
else:
|
|
58
74
|
return False
|
|
59
75
|
|
|
60
|
-
def is_included(self, name: str) -> bool:
|
|
61
|
-
"
|
|
76
|
+
def is_included(self, name: str, is_dir: bool) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
True if the file or directory name matches none of the exclusion patterns.
|
|
79
|
+
|
|
80
|
+
:param name: Name to match against the rule-set.
|
|
81
|
+
:param is_dir: Whether the name identifies a directory.
|
|
82
|
+
:returns: True if the name doesn't match any of the exclusion patterns.
|
|
83
|
+
"""
|
|
62
84
|
|
|
63
|
-
return not self.is_excluded(name)
|
|
85
|
+
return not self.is_excluded(name, is_dir)
|
|
64
86
|
|
|
65
|
-
def filter(self, items: Iterable[
|
|
87
|
+
def filter(self, items: Iterable[Entry]) -> List[Entry]:
|
|
66
88
|
"""
|
|
67
89
|
Returns only those elements from the input that don't match any of the exclusion rules.
|
|
68
90
|
|
|
@@ -70,9 +92,9 @@ class Matcher:
|
|
|
70
92
|
:returns: A filtered list of names that didn't match any of the exclusion rules.
|
|
71
93
|
"""
|
|
72
94
|
|
|
73
|
-
return [item for item in items if self.is_included(item)]
|
|
95
|
+
return [item for item in items if self.is_included(item.name, item.is_dir)]
|
|
74
96
|
|
|
75
|
-
def scandir(self, path: Path) -> List[
|
|
97
|
+
def scandir(self, path: Path) -> List[Entry]:
|
|
76
98
|
"""
|
|
77
99
|
Returns only those entries in a directory whose name doesn't match any of the exclusion rules.
|
|
78
100
|
|
|
@@ -80,4 +102,6 @@ class Matcher:
|
|
|
80
102
|
:returns: A filtered list of entries whose name didn't match any of the exclusion rules.
|
|
81
103
|
"""
|
|
82
104
|
|
|
83
|
-
return self.filter(
|
|
105
|
+
return self.filter(
|
|
106
|
+
Entry(entry.name, entry.is_dir()) for entry in os.scandir(path)
|
|
107
|
+
)
|
md2conf/mermaid.py
CHANGED
|
@@ -49,10 +49,9 @@ def render(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
|
|
|
49
49
|
"--outputFormat",
|
|
50
50
|
output_format,
|
|
51
51
|
]
|
|
52
|
+
root = os.path.dirname(__file__)
|
|
52
53
|
if is_docker():
|
|
53
|
-
cmd.extend(
|
|
54
|
-
["-p", os.path.join(os.path.dirname(__file__), "puppeteer-config.json")]
|
|
55
|
-
)
|
|
54
|
+
cmd.extend(["-p", os.path.join(root, "puppeteer-config.json")])
|
|
56
55
|
LOGGER.debug(f"Executing: {' '.join(cmd)}")
|
|
57
56
|
try:
|
|
58
57
|
proc = subprocess.Popen(
|
md2conf/processor.py
CHANGED
|
@@ -75,13 +75,13 @@ class Processor:
|
|
|
75
75
|
files: List[Path] = []
|
|
76
76
|
directories: List[Path] = []
|
|
77
77
|
for entry in os.scandir(local_dir):
|
|
78
|
-
if matcher.is_excluded(entry.name):
|
|
78
|
+
if matcher.is_excluded(entry.name, entry.is_dir()):
|
|
79
79
|
continue
|
|
80
80
|
|
|
81
81
|
if entry.is_file():
|
|
82
|
-
files.append(
|
|
82
|
+
files.append(Path(local_dir) / entry.name)
|
|
83
83
|
elif entry.is_dir():
|
|
84
|
-
directories.append(
|
|
84
|
+
directories.append(Path(local_dir) / entry.name)
|
|
85
85
|
|
|
86
86
|
for doc in files:
|
|
87
87
|
metadata = self._get_page(doc)
|
md2conf/util.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if sys.version_info >= (3, 9):
|
|
4
|
+
|
|
5
|
+
def removeprefix(string: str, prefix: str) -> str:
|
|
6
|
+
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
7
|
+
|
|
8
|
+
return string.removeprefix(prefix)
|
|
9
|
+
|
|
10
|
+
else:
|
|
11
|
+
|
|
12
|
+
def removeprefix(string: str, prefix: str) -> str:
|
|
13
|
+
"If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
|
|
14
|
+
|
|
15
|
+
if string.startswith(prefix):
|
|
16
|
+
prefix_len = len(prefix)
|
|
17
|
+
return string[prefix_len:]
|
|
18
|
+
else:
|
|
19
|
+
return string
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
md2conf/__init__.py,sha256=1DSbQlz0zNxil7Lbsh7VjmGvJdtKhOjtd67r2elUSjE,402
|
|
2
|
-
md2conf/__main__.py,sha256=_qUspNQmQdhpH4Myh9vXDcauPyUx_FyEzNtaW_c8ytY,6601
|
|
3
|
-
md2conf/api.py,sha256=UZ7mkeE1d_f_bACj8LC-t6d4EqXFQCufbeVVdi4FsTs,16947
|
|
4
|
-
md2conf/application.py,sha256=mQusGnzu-ssFn9-aC_rGsqsWpDtw8qFJDnPW7cRkXC0,7762
|
|
5
|
-
md2conf/converter.py,sha256=_zFk-H4NZuY2Y58enVGgFNubOJv9EI2u8tS7RQRiD3A,30391
|
|
6
|
-
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
7
|
-
md2conf/matcher.py,sha256=SAmXQzQNan05jVcmZ8PEONynj-SEcVrkCHyXvBxEi2Q,2690
|
|
8
|
-
md2conf/mermaid.py,sha256=a7PVcd7kcFBOMw7Z2mOfvWC1JIVR4Q1EkkanLk1SLx0,1981
|
|
9
|
-
md2conf/processor.py,sha256=V_kxpk4da8vzSLx4Zixhf1sEWdVIxKZeJocJvWhOK6Y,4020
|
|
10
|
-
md2conf/properties.py,sha256=2l1tW8HmnrEsXN4-Dtby2tYJQTG1MirRpM3H6ykjQ4c,1858
|
|
11
|
-
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
12
|
-
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
markdown_to_confluence-0.2.2.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
14
|
-
markdown_to_confluence-0.2.2.dist-info/METADATA,sha256=a_CQkC2-De5lcIAudWShsx0m1DIAtA6utrsJKcAi20I,11571
|
|
15
|
-
markdown_to_confluence-0.2.2.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
16
|
-
markdown_to_confluence-0.2.2.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
17
|
-
markdown_to_confluence-0.2.2.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
18
|
-
markdown_to_confluence-0.2.2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
19
|
-
markdown_to_confluence-0.2.2.dist-info/RECORD,,
|
|
File without changes
|
{markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.2.2.dist-info → markdown_to_confluence-0.2.4.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|