markdown-to-confluence 0.3.3__py3-none-any.whl → 0.3.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.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/METADATA +10 -3
- markdown_to_confluence-0.3.4.dist-info/RECORD +22 -0
- {markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/WHEEL +1 -1
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +6 -5
- md2conf/api.py +104 -33
- md2conf/application.py +77 -164
- md2conf/converter.py +33 -24
- md2conf/local.py +132 -0
- md2conf/metadata.py +42 -0
- md2conf/processor.py +158 -88
- markdown_to_confluence-0.3.3.dist-info/RECORD +0 -20
- {markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/zip-safe +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: markdown-to-confluence
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Publish Markdown files to Confluence wiki
|
|
5
5
|
Home-page: https://github.com/hunyadi/md2conf
|
|
6
6
|
Author: Levente Hunyadi
|
|
@@ -62,13 +62,13 @@ Whenever possible, the implementation uses [Confluence REST API v2](https://deve
|
|
|
62
62
|
|
|
63
63
|
## Installation
|
|
64
64
|
|
|
65
|
-
Install the core package from PyPI:
|
|
65
|
+
**Required.** Install the core package from [PyPI](https://pypi.org/project/markdown-to-confluence/):
|
|
66
66
|
|
|
67
67
|
```sh
|
|
68
68
|
pip install markdown-to-confluence
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
|
|
71
|
+
**Optional.** Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
|
|
72
72
|
|
|
73
73
|
```sh
|
|
74
74
|
npm install -g @mermaid-js/mermaid-cli
|
|
@@ -222,6 +222,13 @@ Files that don't have the extension `*.md` are skipped automatically. Hidden dir
|
|
|
222
222
|
|
|
223
223
|
If a matching Confluence page already exists for a Markdown file, the page title in Confluence is left unchanged.
|
|
224
224
|
|
|
225
|
+
### Converting diagrams
|
|
226
|
+
|
|
227
|
+
You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
|
|
228
|
+
|
|
229
|
+
1. Pre-render into an image. The code block is interpreted by and converted into a PNG or SVG image with the Mermaid diagram utility [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). The generated image is then uploaded to Confluence as an attachment to the page. This is the approach we use and support.
|
|
230
|
+
2. Render on demand. The code block is transformed into a [diagram macro](https://atlasauthority.atlassian.net/wiki/spaces/MARKDOWNCLOUD/pages/2946826241/Diagram+Macro), which is processed by Confluence. You need a [Confluence plugin](https://marketplace.atlassian.com/apps/1211438/markdown-html-plantuml-latex-diagrams-open-api-mermaid) to turn macro definitions into images when a Confluence page is visited. This is a contributed feature. As authors of *md2conf*, we don't endorse or support any particular Confluence plugin.
|
|
231
|
+
|
|
225
232
|
### Running the tool
|
|
226
233
|
|
|
227
234
|
You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
markdown_to_confluence-0.3.4.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
2
|
+
md2conf/__init__.py,sha256=9gI6OYCv9-54FzxjNHLOH09H5quUDEMWq9pdbhnwoXM,402
|
|
3
|
+
md2conf/__main__.py,sha256=bFcfmSnTWeuhmDm7bJ3jJabZ2S8W9biuAP6_R-Cc9As,8034
|
|
4
|
+
md2conf/api.py,sha256=ZIYoBXclLbzrrQ_oFRllsTEnQIMbxqd9OD80-AC5qM0,22769
|
|
5
|
+
md2conf/application.py,sha256=eIVeAGUzfdIq1uYLYpTg30UNSq-YcUIY-OgKKK3M4E4,6436
|
|
6
|
+
md2conf/converter.py,sha256=2Sgq1WQd-dCtrdTVrBwhowPC8PmubMNCH1aAcRwntjs,39404
|
|
7
|
+
md2conf/emoji.py,sha256=48QJtOD0F3Be1laYLvAOwe0GxrJS-vcfjtCdiBsNcAc,1960
|
|
8
|
+
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
9
|
+
md2conf/local.py,sha256=AOuwyvPOXrRRPGOTDeoVYkMPJ9MI2zqRGAvHuY35wy4,3884
|
|
10
|
+
md2conf/matcher.py,sha256=FgMFPvGiOqGezCs8OyerfsVo-iIHFoI6LRMzdcjM5UY,3693
|
|
11
|
+
md2conf/mermaid.py,sha256=un_KHBDpG5Zad_QD3HN1uBwUxp4I-HVJYhNKbH7KwcA,2312
|
|
12
|
+
md2conf/metadata.py,sha256=9BtNRsICbKzPTs63P70XekNARePdW1DtdKNJqXh2ZFM,1013
|
|
13
|
+
md2conf/processor.py,sha256=Ko_3WqLK6jM-bEN7OD9Vc3g3vhSjRYawz3fG6uoUsXc,6733
|
|
14
|
+
md2conf/properties.py,sha256=TOCXLdTfYkKjRwZaMgvXw0mNCI4opEUwpBXro2Kv2B4,2467
|
|
15
|
+
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
16
|
+
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
markdown_to_confluence-0.3.4.dist-info/METADATA,sha256=PUtJXudDooVfwOzVtohxweWHMjgDv5CIrDvyqiJ0tlg,17745
|
|
18
|
+
markdown_to_confluence-0.3.4.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
|
19
|
+
markdown_to_confluence-0.3.4.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
20
|
+
markdown_to_confluence-0.3.4.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
21
|
+
markdown_to_confluence-0.3.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
22
|
+
markdown_to_confluence-0.3.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.3.
|
|
8
|
+
__version__ = "0.3.4"
|
|
9
9
|
__author__ = "Levente Hunyadi"
|
|
10
10
|
__copyright__ = "Copyright 2022-2025, Levente Hunyadi"
|
|
11
11
|
__license__ = "MIT"
|
md2conf/__main__.py
CHANGED
|
@@ -22,8 +22,9 @@ import requests
|
|
|
22
22
|
from . import __version__
|
|
23
23
|
from .api import ConfluenceAPI
|
|
24
24
|
from .application import Application
|
|
25
|
-
from .converter import ConfluenceDocumentOptions,
|
|
26
|
-
from .
|
|
25
|
+
from .converter import ConfluenceDocumentOptions, ConfluencePageID
|
|
26
|
+
from .local import LocalConverter
|
|
27
|
+
from .metadata import ConfluenceSiteMetadata
|
|
27
28
|
from .properties import (
|
|
28
29
|
ArgumentError,
|
|
29
30
|
ConfluenceConnectionProperties,
|
|
@@ -199,7 +200,7 @@ def main() -> None:
|
|
|
199
200
|
heading_anchors=args.heading_anchors,
|
|
200
201
|
ignore_invalid_url=args.ignore_invalid_url,
|
|
201
202
|
generated_by=args.generated_by,
|
|
202
|
-
root_page_id=args.root_page,
|
|
203
|
+
root_page_id=ConfluencePageID(args.root_page) if args.root_page else None,
|
|
203
204
|
keep_hierarchy=args.keep_hierarchy,
|
|
204
205
|
render_mermaid=args.render_mermaid,
|
|
205
206
|
diagram_output_format=args.diagram_output_format,
|
|
@@ -219,7 +220,7 @@ def main() -> None:
|
|
|
219
220
|
base_path=site_properties.base_path,
|
|
220
221
|
space_key=site_properties.space_key,
|
|
221
222
|
)
|
|
222
|
-
|
|
223
|
+
LocalConverter(options, site_metadata).process(args.mdpath)
|
|
223
224
|
else:
|
|
224
225
|
try:
|
|
225
226
|
properties = ConfluenceConnectionProperties(
|
|
@@ -237,7 +238,7 @@ def main() -> None:
|
|
|
237
238
|
Application(
|
|
238
239
|
api,
|
|
239
240
|
options,
|
|
240
|
-
).
|
|
241
|
+
).process(args.mdpath)
|
|
241
242
|
except requests.exceptions.HTTPError as err:
|
|
242
243
|
logging.error(err)
|
|
243
244
|
|
md2conf/api.py
CHANGED
|
@@ -7,6 +7,7 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import enum
|
|
10
|
+
import functools
|
|
10
11
|
import io
|
|
11
12
|
import json
|
|
12
13
|
import logging
|
|
@@ -21,6 +22,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
|
|
|
21
22
|
import requests
|
|
22
23
|
|
|
23
24
|
from .converter import ParseError, sanitize_confluence
|
|
25
|
+
from .metadata import ConfluenceSiteMetadata
|
|
24
26
|
from .properties import (
|
|
25
27
|
ArgumentError,
|
|
26
28
|
ConfluenceConnectionProperties,
|
|
@@ -76,7 +78,7 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
|
|
|
76
78
|
LOGGER = logging.getLogger(__name__)
|
|
77
79
|
|
|
78
80
|
|
|
79
|
-
@dataclass
|
|
81
|
+
@dataclass(frozen=True)
|
|
80
82
|
class ConfluenceAttachment:
|
|
81
83
|
id: str
|
|
82
84
|
media_type: str
|
|
@@ -84,14 +86,18 @@ class ConfluenceAttachment:
|
|
|
84
86
|
comment: str
|
|
85
87
|
|
|
86
88
|
|
|
87
|
-
@dataclass
|
|
88
|
-
class
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ConfluencePageMetadata:
|
|
89
91
|
id: str
|
|
90
92
|
space_id: str
|
|
91
93
|
parent_id: str
|
|
92
94
|
parent_type: Optional[ConfluencePageParentContentType]
|
|
93
95
|
title: str
|
|
94
96
|
version: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ConfluencePage(ConfluencePageMetadata):
|
|
95
101
|
content: str
|
|
96
102
|
|
|
97
103
|
|
|
@@ -136,10 +142,12 @@ class ConfluenceAPI:
|
|
|
136
142
|
|
|
137
143
|
|
|
138
144
|
class ConfluenceSession:
|
|
145
|
+
"""
|
|
146
|
+
Information about an open session to a Confluence server.
|
|
147
|
+
"""
|
|
148
|
+
|
|
139
149
|
session: requests.Session
|
|
140
|
-
|
|
141
|
-
base_path: str
|
|
142
|
-
space_key: Optional[str]
|
|
150
|
+
site: ConfluenceSiteMetadata
|
|
143
151
|
|
|
144
152
|
_space_id_to_key: dict[str, str]
|
|
145
153
|
_space_key_to_id: dict[str, str]
|
|
@@ -152,9 +160,7 @@ class ConfluenceSession:
|
|
|
152
160
|
space_key: Optional[str] = None,
|
|
153
161
|
) -> None:
|
|
154
162
|
self.session = session
|
|
155
|
-
self.
|
|
156
|
-
self.base_path = base_path
|
|
157
|
-
self.space_key = space_key
|
|
163
|
+
self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
|
|
158
164
|
|
|
159
165
|
self._space_id_to_key = {}
|
|
160
166
|
self._space_key_to_id = {}
|
|
@@ -178,7 +184,9 @@ class ConfluenceSession:
|
|
|
178
184
|
:returns: A full URL.
|
|
179
185
|
"""
|
|
180
186
|
|
|
181
|
-
base_url =
|
|
187
|
+
base_url = (
|
|
188
|
+
f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
|
|
189
|
+
)
|
|
182
190
|
return build_url(base_url, query)
|
|
183
191
|
|
|
184
192
|
def _invoke(
|
|
@@ -251,6 +259,29 @@ class ConfluenceSession:
|
|
|
251
259
|
|
|
252
260
|
return id
|
|
253
261
|
|
|
262
|
+
def get_space_id(
|
|
263
|
+
self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
|
|
264
|
+
) -> Optional[str]:
|
|
265
|
+
"""
|
|
266
|
+
Coalesce a space ID or space key into a space ID, accounting for site default.
|
|
267
|
+
|
|
268
|
+
:param space_id: A Confluence space ID.
|
|
269
|
+
:param space_key: A Confluence space key.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
if space_id is not None and space_key is not None:
|
|
273
|
+
raise ConfluenceError("either space ID or space key is required; not both")
|
|
274
|
+
|
|
275
|
+
if space_id is not None:
|
|
276
|
+
return space_id
|
|
277
|
+
|
|
278
|
+
space_key = space_key or self.site.space_key
|
|
279
|
+
if space_key is not None:
|
|
280
|
+
return self.space_key_to_id(space_key)
|
|
281
|
+
|
|
282
|
+
# space ID and key are unset, and no default space is configured
|
|
283
|
+
return None
|
|
284
|
+
|
|
254
285
|
def get_attachment_by_name(
|
|
255
286
|
self, page_id: str, filename: str
|
|
256
287
|
) -> ConfluenceAttachment:
|
|
@@ -393,6 +424,7 @@ class ConfluenceSession:
|
|
|
393
424
|
self,
|
|
394
425
|
title: str,
|
|
395
426
|
*,
|
|
427
|
+
space_id: Optional[str] = None,
|
|
396
428
|
space_key: Optional[str] = None,
|
|
397
429
|
) -> str:
|
|
398
430
|
"""
|
|
@@ -408,9 +440,9 @@ class ConfluenceSession:
|
|
|
408
440
|
query = {
|
|
409
441
|
"title": title,
|
|
410
442
|
}
|
|
411
|
-
|
|
412
|
-
if
|
|
413
|
-
query["space-id"] =
|
|
443
|
+
space_id = self.get_space_id(space_id=space_id, space_key=space_key)
|
|
444
|
+
if space_id is not None:
|
|
445
|
+
query["space-id"] = space_id
|
|
414
446
|
|
|
415
447
|
payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
|
|
416
448
|
payload = typing.cast(dict[str, JsonType], payload)
|
|
@@ -425,10 +457,10 @@ class ConfluenceSession:
|
|
|
425
457
|
|
|
426
458
|
def get_page(self, page_id: str) -> ConfluencePage:
|
|
427
459
|
"""
|
|
428
|
-
Retrieve Confluence wiki page details.
|
|
460
|
+
Retrieve Confluence wiki page details and content.
|
|
429
461
|
|
|
430
462
|
:param page_id: The Confluence page ID.
|
|
431
|
-
:returns: Confluence page info.
|
|
463
|
+
:returns: Confluence page info and content.
|
|
432
464
|
"""
|
|
433
465
|
|
|
434
466
|
path = f"/pages/{page_id}"
|
|
@@ -453,6 +485,33 @@ class ConfluenceSession:
|
|
|
453
485
|
content=typing.cast(str, storage["value"]),
|
|
454
486
|
)
|
|
455
487
|
|
|
488
|
+
@functools.cache
|
|
489
|
+
def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata:
|
|
490
|
+
"""
|
|
491
|
+
Retrieve Confluence wiki page details.
|
|
492
|
+
|
|
493
|
+
:param page_id: The Confluence page ID.
|
|
494
|
+
:returns: Confluence page info.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
path = f"/pages/{page_id}"
|
|
498
|
+
payload = self._invoke(ConfluenceVersion.VERSION_2, path)
|
|
499
|
+
data = typing.cast(dict[str, JsonType], payload)
|
|
500
|
+
version = typing.cast(dict[str, JsonType], data["version"])
|
|
501
|
+
|
|
502
|
+
return ConfluencePageMetadata(
|
|
503
|
+
id=page_id,
|
|
504
|
+
space_id=typing.cast(str, data["spaceId"]),
|
|
505
|
+
parent_id=typing.cast(str, data["parentId"]),
|
|
506
|
+
parent_type=(
|
|
507
|
+
ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
|
|
508
|
+
if data["parentType"] is not None
|
|
509
|
+
else None
|
|
510
|
+
),
|
|
511
|
+
title=typing.cast(str, data["title"]),
|
|
512
|
+
version=typing.cast(int, version["number"]),
|
|
513
|
+
)
|
|
514
|
+
|
|
456
515
|
def get_page_version(self, page_id: str) -> int:
|
|
457
516
|
"""
|
|
458
517
|
Retrieve a Confluence wiki page version.
|
|
@@ -507,26 +566,21 @@ class ConfluenceSession:
|
|
|
507
566
|
|
|
508
567
|
def create_page(
|
|
509
568
|
self,
|
|
510
|
-
|
|
569
|
+
parent_id: str,
|
|
511
570
|
title: str,
|
|
512
571
|
new_content: str,
|
|
513
|
-
*,
|
|
514
|
-
space_key: Optional[str] = None,
|
|
515
572
|
) -> ConfluencePage:
|
|
516
573
|
"""
|
|
517
574
|
Create a new page via Confluence API.
|
|
518
575
|
"""
|
|
519
576
|
|
|
520
|
-
|
|
521
|
-
if coalesced_space_key is None:
|
|
522
|
-
raise ArgumentError("Confluence space key required for creating a new page")
|
|
523
|
-
|
|
577
|
+
parent_page = self.get_page_metadata(parent_id)
|
|
524
578
|
path = "/pages/"
|
|
525
579
|
query = {
|
|
526
|
-
"spaceId":
|
|
580
|
+
"spaceId": parent_page.space_id,
|
|
527
581
|
"status": "current",
|
|
528
582
|
"title": title,
|
|
529
|
-
"parentId":
|
|
583
|
+
"parentId": parent_id,
|
|
530
584
|
"body": {"storage": {"value": new_content, "representation": "storage"}},
|
|
531
585
|
}
|
|
532
586
|
|
|
@@ -584,13 +638,24 @@ class ConfluenceSession:
|
|
|
584
638
|
response.raise_for_status()
|
|
585
639
|
|
|
586
640
|
def page_exists(
|
|
587
|
-
self,
|
|
641
|
+
self,
|
|
642
|
+
title: str,
|
|
643
|
+
*,
|
|
644
|
+
space_id: Optional[str] = None,
|
|
645
|
+
space_key: Optional[str] = None,
|
|
588
646
|
) -> Optional[str]:
|
|
647
|
+
"""
|
|
648
|
+
Check if a Confluence page exists with the given title.
|
|
649
|
+
|
|
650
|
+
:param title: Page title. Pages in the same Confluence space must have a unique title.
|
|
651
|
+
:param space_key: Identifies the Confluence space.
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
space_id = self.get_space_id(space_id=space_id, space_key=space_key)
|
|
589
655
|
path = "/pages"
|
|
590
|
-
coalesced_space_key = space_key or self.space_key
|
|
591
656
|
query = {"title": title}
|
|
592
|
-
if
|
|
593
|
-
query["space-id"] =
|
|
657
|
+
if space_id is not None:
|
|
658
|
+
query["space-id"] = space_id
|
|
594
659
|
|
|
595
660
|
LOGGER.info("Checking if page exists with title: %s", title)
|
|
596
661
|
|
|
@@ -609,14 +674,20 @@ class ConfluenceSession:
|
|
|
609
674
|
else:
|
|
610
675
|
return None
|
|
611
676
|
|
|
612
|
-
def get_or_create_page(
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
677
|
+
def get_or_create_page(self, title: str, parent_id: str) -> ConfluencePage:
|
|
678
|
+
"""
|
|
679
|
+
Find a page with the given title, or create a new page if no such page exists.
|
|
680
|
+
|
|
681
|
+
:param title: Page title. Pages in the same Confluence space must have a unique title.
|
|
682
|
+
:param parent_id: Identifies the parent page for a new child page.
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
parent_page = self.get_page_metadata(parent_id)
|
|
686
|
+
page_id = self.page_exists(title, space_id=parent_page.space_id)
|
|
616
687
|
|
|
617
688
|
if page_id is not None:
|
|
618
689
|
LOGGER.debug("Retrieving existing page: %s", page_id)
|
|
619
690
|
return self.get_page(page_id)
|
|
620
691
|
else:
|
|
621
692
|
LOGGER.debug("Creating new page with title: %s", title)
|
|
622
|
-
return self.create_page(parent_id, title, ""
|
|
693
|
+
return self.create_page(parent_id, title, "")
|
md2conf/application.py
CHANGED
|
@@ -8,7 +8,6 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
8
8
|
|
|
9
9
|
import hashlib
|
|
10
10
|
import logging
|
|
11
|
-
import os
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
from typing import Optional
|
|
14
13
|
|
|
@@ -16,167 +15,43 @@ from .api import ConfluencePage, ConfluenceSession
|
|
|
16
15
|
from .converter import (
|
|
17
16
|
ConfluenceDocument,
|
|
18
17
|
ConfluenceDocumentOptions,
|
|
19
|
-
|
|
20
|
-
ConfluenceQualifiedID,
|
|
21
|
-
ConfluenceSiteMetadata,
|
|
18
|
+
ConfluencePageID,
|
|
22
19
|
attachment_name,
|
|
23
20
|
extract_frontmatter_title,
|
|
24
21
|
extract_qualified_id,
|
|
25
|
-
read_qualified_id,
|
|
26
22
|
)
|
|
27
|
-
from .
|
|
28
|
-
from .
|
|
23
|
+
from .metadata import ConfluencePageMetadata
|
|
24
|
+
from .processor import Converter, Processor, ProcessorFactory
|
|
25
|
+
from .properties import PageError
|
|
29
26
|
|
|
30
27
|
LOGGER = logging.getLogger(__name__)
|
|
31
28
|
|
|
32
29
|
|
|
33
|
-
class
|
|
34
|
-
"
|
|
30
|
+
class SynchronizingProcessor(Processor):
|
|
31
|
+
"""
|
|
32
|
+
Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
|
|
33
|
+
"""
|
|
35
34
|
|
|
36
35
|
api: ConfluenceSession
|
|
37
|
-
options: ConfluenceDocumentOptions
|
|
38
36
|
|
|
39
37
|
def __init__(
|
|
40
|
-
self, api: ConfluenceSession, options: ConfluenceDocumentOptions
|
|
41
|
-
) -> None:
|
|
42
|
-
self.api = api
|
|
43
|
-
self.options = options
|
|
44
|
-
|
|
45
|
-
def synchronize(self, path: Path) -> None:
|
|
46
|
-
"Synchronizes a single Markdown page or a directory of Markdown pages."
|
|
47
|
-
|
|
48
|
-
path = path.resolve(True)
|
|
49
|
-
if path.is_dir():
|
|
50
|
-
self.synchronize_directory(path)
|
|
51
|
-
elif path.is_file():
|
|
52
|
-
self.synchronize_page(path)
|
|
53
|
-
else:
|
|
54
|
-
raise ArgumentError(f"expected: valid file or directory path; got: {path}")
|
|
55
|
-
|
|
56
|
-
def synchronize_page(
|
|
57
|
-
self, page_path: Path, root_dir: Optional[Path] = None
|
|
58
|
-
) -> None:
|
|
59
|
-
"Synchronizes a single Markdown page with Confluence."
|
|
60
|
-
|
|
61
|
-
page_path = page_path.resolve(True)
|
|
62
|
-
if root_dir is None:
|
|
63
|
-
root_dir = page_path.parent
|
|
64
|
-
else:
|
|
65
|
-
root_dir = root_dir.resolve(True)
|
|
66
|
-
|
|
67
|
-
self._synchronize_page(page_path, root_dir, {})
|
|
68
|
-
|
|
69
|
-
def synchronize_directory(
|
|
70
|
-
self, local_dir: Path, root_dir: Optional[Path] = None
|
|
71
|
-
) -> None:
|
|
72
|
-
"Synchronizes a directory of Markdown pages with Confluence."
|
|
73
|
-
|
|
74
|
-
local_dir = local_dir.resolve(True)
|
|
75
|
-
if root_dir is None:
|
|
76
|
-
root_dir = local_dir
|
|
77
|
-
else:
|
|
78
|
-
root_dir = root_dir.resolve(True)
|
|
79
|
-
|
|
80
|
-
LOGGER.info("Synchronizing directory: %s", local_dir)
|
|
81
|
-
|
|
82
|
-
# Step 1: build index of all page metadata
|
|
83
|
-
page_metadata: dict[Path, ConfluencePageMetadata] = {}
|
|
84
|
-
root_id = (
|
|
85
|
-
ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
|
|
86
|
-
if self.options.root_page_id
|
|
87
|
-
else None
|
|
88
|
-
)
|
|
89
|
-
self._index_directory(local_dir, root_dir, root_id, page_metadata)
|
|
90
|
-
LOGGER.info("Indexed %d page(s)", len(page_metadata))
|
|
91
|
-
|
|
92
|
-
# Step 2: convert each page
|
|
93
|
-
for page_path in page_metadata.keys():
|
|
94
|
-
self._synchronize_page(page_path, root_dir, page_metadata)
|
|
95
|
-
|
|
96
|
-
def _synchronize_page(
|
|
97
|
-
self,
|
|
98
|
-
page_path: Path,
|
|
99
|
-
root_dir: Path,
|
|
100
|
-
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
101
|
-
) -> None:
|
|
102
|
-
base_path = page_path.parent
|
|
103
|
-
|
|
104
|
-
LOGGER.info("Synchronizing page: %s", page_path)
|
|
105
|
-
site_metadata = ConfluenceSiteMetadata(
|
|
106
|
-
domain=self.api.domain,
|
|
107
|
-
base_path=self.api.base_path,
|
|
108
|
-
space_key=self.api.space_key,
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
document = ConfluenceDocument.create(
|
|
112
|
-
page_path, self.options, root_dir, site_metadata, page_metadata
|
|
113
|
-
)
|
|
114
|
-
self._update_document(document, base_path)
|
|
115
|
-
|
|
116
|
-
def _index_directory(
|
|
117
|
-
self,
|
|
118
|
-
local_dir: Path,
|
|
119
|
-
root_dir: Path,
|
|
120
|
-
root_id: Optional[ConfluenceQualifiedID],
|
|
121
|
-
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
38
|
+
self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path
|
|
122
39
|
) -> None:
|
|
123
|
-
"
|
|
124
|
-
|
|
125
|
-
LOGGER.info("Indexing directory: %s", local_dir)
|
|
126
|
-
|
|
127
|
-
matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
|
|
128
|
-
|
|
129
|
-
files: list[Path] = []
|
|
130
|
-
directories: list[Path] = []
|
|
131
|
-
for entry in os.scandir(local_dir):
|
|
132
|
-
if matcher.is_excluded(entry.name, entry.is_dir()):
|
|
133
|
-
continue
|
|
134
|
-
|
|
135
|
-
if entry.is_file():
|
|
136
|
-
files.append(Path(local_dir) / entry.name)
|
|
137
|
-
elif entry.is_dir():
|
|
138
|
-
directories.append(Path(local_dir) / entry.name)
|
|
139
|
-
|
|
140
|
-
# make page act as parent node in Confluence
|
|
141
|
-
parent_doc: Optional[Path] = None
|
|
142
|
-
if (Path(local_dir) / "index.md") in files:
|
|
143
|
-
parent_doc = Path(local_dir) / "index.md"
|
|
144
|
-
elif (Path(local_dir) / "README.md") in files:
|
|
145
|
-
parent_doc = Path(local_dir) / "README.md"
|
|
146
|
-
elif (Path(local_dir) / f"{local_dir.name}.md") in files:
|
|
147
|
-
parent_doc = Path(local_dir) / f"{local_dir.name}.md"
|
|
148
|
-
|
|
149
|
-
if parent_doc is None and self.options.keep_hierarchy:
|
|
150
|
-
parent_doc = Path(local_dir) / "index.md"
|
|
151
|
-
|
|
152
|
-
# create a blank page in Confluence for the directory entry
|
|
153
|
-
with open(parent_doc, "w"):
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
|
-
if parent_doc is not None:
|
|
157
|
-
files.remove(parent_doc)
|
|
158
|
-
|
|
159
|
-
metadata = self._get_or_create_page(parent_doc, root_dir, root_id)
|
|
160
|
-
LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
|
|
161
|
-
page_metadata[parent_doc] = metadata
|
|
162
|
-
|
|
163
|
-
parent_id = read_qualified_id(parent_doc) or root_id
|
|
164
|
-
else:
|
|
165
|
-
parent_id = root_id
|
|
40
|
+
"""
|
|
41
|
+
Initializes a new processor instance.
|
|
166
42
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
43
|
+
:param api: Holds information about an open session to a Confluence server.
|
|
44
|
+
:param options: Options that control the generated page content.
|
|
45
|
+
:param root_dir: File system directory that acts as topmost root node.
|
|
46
|
+
"""
|
|
171
47
|
|
|
172
|
-
|
|
173
|
-
|
|
48
|
+
super().__init__(options, api.site, root_dir)
|
|
49
|
+
self.api = api
|
|
174
50
|
|
|
175
51
|
def _get_or_create_page(
|
|
176
52
|
self,
|
|
177
53
|
absolute_path: Path,
|
|
178
|
-
|
|
179
|
-
parent_id: Optional[ConfluenceQualifiedID],
|
|
54
|
+
parent_id: Optional[ConfluencePageID],
|
|
180
55
|
*,
|
|
181
56
|
title: Optional[str] = None,
|
|
182
57
|
) -> ConfluencePageMetadata:
|
|
@@ -186,13 +61,13 @@ class Application:
|
|
|
186
61
|
|
|
187
62
|
# parse file
|
|
188
63
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
189
|
-
|
|
64
|
+
text = f.read()
|
|
190
65
|
|
|
191
|
-
qualified_id,
|
|
66
|
+
qualified_id, text = extract_qualified_id(text)
|
|
192
67
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
68
|
+
overwrite = False
|
|
69
|
+
if qualified_id is None:
|
|
70
|
+
# create new Confluence page
|
|
196
71
|
if parent_id is None:
|
|
197
72
|
raise PageError(
|
|
198
73
|
f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
|
|
@@ -200,29 +75,32 @@ class Application:
|
|
|
200
75
|
|
|
201
76
|
# assign title from front-matter if present
|
|
202
77
|
if title is None:
|
|
203
|
-
title, _ = extract_frontmatter_title(
|
|
78
|
+
title, _ = extract_frontmatter_title(text)
|
|
204
79
|
|
|
205
80
|
# use file name (without extension) and path hash if no title is supplied
|
|
206
81
|
if title is None:
|
|
207
|
-
|
|
82
|
+
overwrite = True
|
|
83
|
+
relative_path = absolute_path.relative_to(self.root_dir)
|
|
208
84
|
hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
|
|
209
85
|
digest = "".join(f"{c:x}" for c in hash.digest())
|
|
210
86
|
title = f"{absolute_path.stem} [{digest}]"
|
|
211
87
|
|
|
212
|
-
confluence_page = self._create_page(
|
|
213
|
-
|
|
214
|
-
|
|
88
|
+
confluence_page = self._create_page(absolute_path, text, title, parent_id)
|
|
89
|
+
else:
|
|
90
|
+
# look up existing Confluence page
|
|
91
|
+
confluence_page = self.api.get_page(qualified_id.page_id)
|
|
215
92
|
|
|
216
93
|
space_key = (
|
|
217
94
|
self.api.space_id_to_key(confluence_page.space_id)
|
|
218
95
|
if confluence_page.space_id
|
|
219
|
-
else self.
|
|
96
|
+
else self.site.space_key
|
|
220
97
|
)
|
|
221
98
|
|
|
222
99
|
return ConfluencePageMetadata(
|
|
223
100
|
page_id=confluence_page.id,
|
|
224
101
|
space_key=space_key,
|
|
225
|
-
title=confluence_page.title
|
|
102
|
+
title=confluence_page.title,
|
|
103
|
+
overwrite=overwrite,
|
|
226
104
|
)
|
|
227
105
|
|
|
228
106
|
def _create_page(
|
|
@@ -230,13 +108,13 @@ class Application:
|
|
|
230
108
|
absolute_path: Path,
|
|
231
109
|
document: str,
|
|
232
110
|
title: str,
|
|
233
|
-
parent_id:
|
|
111
|
+
parent_id: ConfluencePageID,
|
|
234
112
|
) -> ConfluencePage:
|
|
235
|
-
"
|
|
113
|
+
"""
|
|
114
|
+
Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
|
|
115
|
+
"""
|
|
236
116
|
|
|
237
|
-
confluence_page = self.api.get_or_create_page(
|
|
238
|
-
title, parent_id.page_id, space_key=parent_id.space_key
|
|
239
|
-
)
|
|
117
|
+
confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
|
|
240
118
|
self._update_markdown(
|
|
241
119
|
absolute_path,
|
|
242
120
|
document,
|
|
@@ -245,9 +123,14 @@ class Application:
|
|
|
245
123
|
)
|
|
246
124
|
return confluence_page
|
|
247
125
|
|
|
248
|
-
def
|
|
249
|
-
"
|
|
126
|
+
def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Saves a new version of a Confluence document.
|
|
129
|
+
|
|
130
|
+
Invokes Confluence REST API to persist the new version.
|
|
131
|
+
"""
|
|
250
132
|
|
|
133
|
+
base_path = path.parent
|
|
251
134
|
for image in document.images:
|
|
252
135
|
self.api.upload_attachment(
|
|
253
136
|
document.id.page_id,
|
|
@@ -263,8 +146,12 @@ class Application:
|
|
|
263
146
|
)
|
|
264
147
|
|
|
265
148
|
content = document.xhtml()
|
|
149
|
+
|
|
150
|
+
# leave title as it is for existing pages, update title for pages with randomly assigned title
|
|
151
|
+
title = document.title if self.page_metadata[path].overwrite else None
|
|
152
|
+
|
|
266
153
|
LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
|
|
267
|
-
self.api.update_page(document.id.page_id, content, title=
|
|
154
|
+
self.api.update_page(document.id.page_id, content, title=title)
|
|
268
155
|
|
|
269
156
|
def _update_markdown(
|
|
270
157
|
self,
|
|
@@ -273,7 +160,9 @@ class Application:
|
|
|
273
160
|
page_id: str,
|
|
274
161
|
space_key: Optional[str],
|
|
275
162
|
) -> None:
|
|
276
|
-
"
|
|
163
|
+
"""
|
|
164
|
+
Writes the Confluence page ID and space key at the beginning of the Markdown file.
|
|
165
|
+
"""
|
|
277
166
|
|
|
278
167
|
content: list[str] = []
|
|
279
168
|
|
|
@@ -293,3 +182,27 @@ class Application:
|
|
|
293
182
|
|
|
294
183
|
with open(path, "w", encoding="utf-8") as file:
|
|
295
184
|
file.write("\n".join(content))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SynchronizingProcessorFactory(ProcessorFactory):
|
|
188
|
+
api: ConfluenceSession
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self, api: ConfluenceSession, options: ConfluenceDocumentOptions
|
|
192
|
+
) -> None:
|
|
193
|
+
super().__init__(options, api.site)
|
|
194
|
+
self.api = api
|
|
195
|
+
|
|
196
|
+
def create(self, root_dir: Path) -> Processor:
|
|
197
|
+
return SynchronizingProcessor(self.api, self.options, root_dir)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Application(Converter):
|
|
201
|
+
"""
|
|
202
|
+
The entry point for Markdown to Confluence conversion.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self, api: ConfluenceSession, options: ConfluenceDocumentOptions
|
|
207
|
+
) -> None:
|
|
208
|
+
super().__init__(SynchronizingProcessorFactory(api, options))
|
md2conf/converter.py
CHANGED
|
@@ -26,6 +26,7 @@ import yaml
|
|
|
26
26
|
from lxml.builder import ElementMaker
|
|
27
27
|
|
|
28
28
|
from .mermaid import render_diagram
|
|
29
|
+
from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
|
|
29
30
|
from .properties import PageError
|
|
30
31
|
|
|
31
32
|
namespaces = {
|
|
@@ -142,8 +143,8 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
|
|
|
142
143
|
|
|
143
144
|
try:
|
|
144
145
|
return ET.fromstringlist(data, parser=parser)
|
|
145
|
-
except ET.XMLSyntaxError as
|
|
146
|
-
raise ParseError(
|
|
146
|
+
except ET.XMLSyntaxError as ex:
|
|
147
|
+
raise ParseError() from ex
|
|
147
148
|
|
|
148
149
|
|
|
149
150
|
def elements_from_strings(items: list[str]) -> ET._Element:
|
|
@@ -240,20 +241,6 @@ _languages = [
|
|
|
240
241
|
]
|
|
241
242
|
|
|
242
243
|
|
|
243
|
-
@dataclass
|
|
244
|
-
class ConfluenceSiteMetadata:
|
|
245
|
-
domain: str
|
|
246
|
-
base_path: str
|
|
247
|
-
space_key: Optional[str]
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
@dataclass
|
|
251
|
-
class ConfluencePageMetadata:
|
|
252
|
-
page_id: str
|
|
253
|
-
space_key: Optional[str]
|
|
254
|
-
title: str
|
|
255
|
-
|
|
256
|
-
|
|
257
244
|
class NodeVisitor:
|
|
258
245
|
def visit(self, node: ET._Element) -> None:
|
|
259
246
|
"Recursively visits all descendants of this node."
|
|
@@ -974,6 +961,14 @@ def extract_value(pattern: str, text: str) -> tuple[Optional[str], str]:
|
|
|
974
961
|
return value, text
|
|
975
962
|
|
|
976
963
|
|
|
964
|
+
@dataclass
|
|
965
|
+
class ConfluencePageID:
|
|
966
|
+
page_id: str
|
|
967
|
+
|
|
968
|
+
def __init__(self, page_id: str):
|
|
969
|
+
self.page_id = page_id
|
|
970
|
+
|
|
971
|
+
|
|
977
972
|
@dataclass
|
|
978
973
|
class ConfluenceQualifiedID:
|
|
979
974
|
page_id: str
|
|
@@ -1048,13 +1043,17 @@ class ConfluenceDocumentOptions:
|
|
|
1048
1043
|
ignore_invalid_url: bool = False
|
|
1049
1044
|
heading_anchors: bool = False
|
|
1050
1045
|
generated_by: Optional[str] = "This page has been generated with a tool."
|
|
1051
|
-
root_page_id: Optional[
|
|
1046
|
+
root_page_id: Optional[ConfluencePageID] = None
|
|
1052
1047
|
keep_hierarchy: bool = False
|
|
1053
1048
|
render_mermaid: bool = False
|
|
1054
1049
|
diagram_output_format: Literal["png", "svg"] = "png"
|
|
1055
1050
|
webui_links: bool = False
|
|
1056
1051
|
|
|
1057
1052
|
|
|
1053
|
+
class ConversionError(RuntimeError):
|
|
1054
|
+
"Raised when a Markdown document cannot be converted to Confluence Storage Format."
|
|
1055
|
+
|
|
1056
|
+
|
|
1058
1057
|
class ConfluenceDocument:
|
|
1059
1058
|
id: ConfluenceQualifiedID
|
|
1060
1059
|
title: Optional[str]
|
|
@@ -1107,32 +1106,42 @@ class ConfluenceDocument:
|
|
|
1107
1106
|
self.options = options
|
|
1108
1107
|
self.id = qualified_id
|
|
1109
1108
|
|
|
1109
|
+
# extract frontmatter
|
|
1110
|
+
self.title, text = extract_frontmatter_title(text)
|
|
1111
|
+
|
|
1110
1112
|
# extract 'generated-by' tag text
|
|
1111
1113
|
generated_by_tag, text = extract_value(
|
|
1112
1114
|
r"<!--\s+generated-by:\s*(.*)\s+-->", text
|
|
1113
1115
|
)
|
|
1114
1116
|
|
|
1115
|
-
# extract frontmatter
|
|
1116
|
-
self.title, text = extract_frontmatter_title(text)
|
|
1117
|
-
|
|
1118
1117
|
# convert to HTML
|
|
1119
1118
|
html = markdown_to_html(text)
|
|
1120
1119
|
|
|
1121
1120
|
# parse Markdown document
|
|
1122
1121
|
if self.options.generated_by is not None:
|
|
1123
|
-
generated_by = self.options.generated_by
|
|
1124
1122
|
if generated_by_tag is not None:
|
|
1125
|
-
|
|
1123
|
+
generated_by_text = generated_by_tag
|
|
1124
|
+
else:
|
|
1125
|
+
generated_by_text = self.options.generated_by
|
|
1126
|
+
else:
|
|
1127
|
+
generated_by_text = None
|
|
1128
|
+
|
|
1129
|
+
if generated_by_text is not None:
|
|
1130
|
+
generated_by_html = markdown_to_html(generated_by_text)
|
|
1126
1131
|
|
|
1127
1132
|
content = [
|
|
1128
1133
|
'<ac:structured-macro ac:name="info" ac:schema-version="1">',
|
|
1129
|
-
f"<ac:rich-text-body
|
|
1134
|
+
f"<ac:rich-text-body>{generated_by_html}</ac:rich-text-body>",
|
|
1130
1135
|
"</ac:structured-macro>",
|
|
1131
1136
|
html,
|
|
1132
1137
|
]
|
|
1133
1138
|
else:
|
|
1134
1139
|
content = [html]
|
|
1135
|
-
|
|
1140
|
+
|
|
1141
|
+
try:
|
|
1142
|
+
self.root = elements_from_strings(content)
|
|
1143
|
+
except ParseError as ex:
|
|
1144
|
+
raise ConversionError(path) from ex
|
|
1136
1145
|
|
|
1137
1146
|
converter = ConfluenceStorageFormatConverter(
|
|
1138
1147
|
ConfluenceConverterOptions(
|
md2conf/local.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .converter import (
|
|
16
|
+
ConfluenceDocument,
|
|
17
|
+
ConfluenceDocumentOptions,
|
|
18
|
+
ConfluencePageID,
|
|
19
|
+
ConfluenceQualifiedID,
|
|
20
|
+
extract_qualified_id,
|
|
21
|
+
)
|
|
22
|
+
from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
|
|
23
|
+
from .processor import Converter, Processor, ProcessorFactory
|
|
24
|
+
from .properties import PageError
|
|
25
|
+
|
|
26
|
+
LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LocalProcessor(Processor):
|
|
30
|
+
"""
|
|
31
|
+
Transforms a single Markdown page or a directory of Markdown pages into Confluence Storage Format (CSF) documents.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
options: ConfluenceDocumentOptions,
|
|
37
|
+
site: ConfluenceSiteMetadata,
|
|
38
|
+
*,
|
|
39
|
+
out_dir: Optional[Path],
|
|
40
|
+
root_dir: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Initializes a new processor instance.
|
|
44
|
+
|
|
45
|
+
:param options: Options that control the generated page content.
|
|
46
|
+
:param site: Data associated with a Confluence wiki site.
|
|
47
|
+
:param out_dir: File system directory to write generated CSF documents to.
|
|
48
|
+
:param root_dir: File system directory that acts as topmost root node.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
super().__init__(options, site, root_dir)
|
|
52
|
+
self.out_dir = out_dir or root_dir
|
|
53
|
+
|
|
54
|
+
def _get_or_create_page(
|
|
55
|
+
self,
|
|
56
|
+
absolute_path: Path,
|
|
57
|
+
parent_id: Optional[ConfluencePageID],
|
|
58
|
+
*,
|
|
59
|
+
title: Optional[str] = None,
|
|
60
|
+
) -> ConfluencePageMetadata:
|
|
61
|
+
"""
|
|
62
|
+
Extracts metadata from a Markdown file.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# parse file
|
|
66
|
+
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
67
|
+
text = f.read()
|
|
68
|
+
|
|
69
|
+
qualified_id, text = extract_qualified_id(text)
|
|
70
|
+
|
|
71
|
+
if qualified_id is None:
|
|
72
|
+
if parent_id is None:
|
|
73
|
+
raise PageError(
|
|
74
|
+
f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
hash = hashlib.md5(text.encode("utf-8"))
|
|
78
|
+
digest = "".join(f"{c:x}" for c in hash.digest())
|
|
79
|
+
LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
|
|
80
|
+
qualified_id = ConfluenceQualifiedID(digest)
|
|
81
|
+
|
|
82
|
+
return ConfluencePageMetadata(
|
|
83
|
+
page_id=qualified_id.page_id,
|
|
84
|
+
space_key=qualified_id.space_key,
|
|
85
|
+
title="",
|
|
86
|
+
overwrite=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Saves a new version of a Confluence document.
|
|
92
|
+
|
|
93
|
+
A derived class may invoke Confluence REST API to persist the new version.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
content = document.xhtml()
|
|
97
|
+
out_path = self.out_dir / path.relative_to(self.root_dir).with_suffix(".csf")
|
|
98
|
+
os.makedirs(out_path.parent, exist_ok=True)
|
|
99
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
100
|
+
f.write(content)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LocalProcessorFactory(ProcessorFactory):
|
|
104
|
+
out_dir: Optional[Path]
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
options: ConfluenceDocumentOptions,
|
|
109
|
+
site: ConfluenceSiteMetadata,
|
|
110
|
+
out_dir: Optional[Path] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
super().__init__(options, site)
|
|
113
|
+
self.out_dir = out_dir
|
|
114
|
+
|
|
115
|
+
def create(self, root_dir: Path) -> Processor:
|
|
116
|
+
return LocalProcessor(
|
|
117
|
+
self.options, self.site, out_dir=self.out_dir, root_dir=root_dir
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class LocalConverter(Converter):
|
|
122
|
+
"""
|
|
123
|
+
The entry point for Markdown to Confluence conversion.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
options: ConfluenceDocumentOptions,
|
|
129
|
+
site: ConfluenceSiteMetadata,
|
|
130
|
+
out_dir: Optional[Path] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
super().__init__(LocalProcessorFactory(options, site, out_dir))
|
md2conf/metadata.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ConfluenceSiteMetadata:
|
|
15
|
+
"""
|
|
16
|
+
Data associated with a Confluence wiki site.
|
|
17
|
+
|
|
18
|
+
:param domain: Confluence organization domain (e.g. `levente-hunyadi.atlassian.net`).
|
|
19
|
+
:param base_path: Base path for Confluence (default: `/wiki/`).
|
|
20
|
+
:param space_key: Confluence space key for new pages (e.g. `~hunyadi` or `INST`).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
domain: str
|
|
24
|
+
base_path: str
|
|
25
|
+
space_key: Optional[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ConfluencePageMetadata:
|
|
30
|
+
"""
|
|
31
|
+
Data associated with a Confluence page.
|
|
32
|
+
|
|
33
|
+
:param page_id: Confluence page ID.
|
|
34
|
+
:param space_key: Confluence space key.
|
|
35
|
+
:param title: Document title.
|
|
36
|
+
:param overwrite: True if operations are allowed to update document properties (e.g. title).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
page_id: str
|
|
40
|
+
space_key: Optional[str]
|
|
41
|
+
title: str
|
|
42
|
+
overwrite: bool
|
md2conf/processor.py
CHANGED
|
@@ -6,101 +6,96 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import hashlib
|
|
10
9
|
import logging
|
|
11
10
|
import os
|
|
11
|
+
from abc import abstractmethod
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Optional
|
|
14
14
|
|
|
15
|
-
from .converter import
|
|
16
|
-
ConfluenceDocument,
|
|
17
|
-
ConfluenceDocumentOptions,
|
|
18
|
-
ConfluencePageMetadata,
|
|
19
|
-
ConfluenceQualifiedID,
|
|
20
|
-
ConfluenceSiteMetadata,
|
|
21
|
-
extract_qualified_id,
|
|
22
|
-
)
|
|
15
|
+
from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
|
|
23
16
|
from .matcher import Matcher, MatcherOptions
|
|
17
|
+
from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
|
|
24
18
|
from .properties import ArgumentError
|
|
25
19
|
|
|
26
20
|
LOGGER = logging.getLogger(__name__)
|
|
27
21
|
|
|
28
22
|
|
|
29
23
|
class Processor:
|
|
24
|
+
"""
|
|
25
|
+
Processes a single Markdown page or a directory of Markdown pages.
|
|
26
|
+
"""
|
|
27
|
+
|
|
30
28
|
options: ConfluenceDocumentOptions
|
|
31
|
-
|
|
29
|
+
site: ConfluenceSiteMetadata
|
|
30
|
+
root_dir: Path
|
|
31
|
+
|
|
32
|
+
page_metadata: dict[Path, ConfluencePageMetadata]
|
|
32
33
|
|
|
33
34
|
def __init__(
|
|
34
|
-
self,
|
|
35
|
+
self,
|
|
36
|
+
options: ConfluenceDocumentOptions,
|
|
37
|
+
site: ConfluenceSiteMetadata,
|
|
38
|
+
root_dir: Path,
|
|
35
39
|
) -> None:
|
|
36
40
|
self.options = options
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
def process(self, path: Path) -> None:
|
|
40
|
-
"Processes a single Markdown file or a directory of Markdown files."
|
|
41
|
+
self.site = site
|
|
42
|
+
self.root_dir = root_dir
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
if path.is_dir():
|
|
44
|
-
self.process_directory(path)
|
|
45
|
-
elif path.is_file():
|
|
46
|
-
self.process_page(path)
|
|
47
|
-
else:
|
|
48
|
-
raise ArgumentError(f"expected: valid file or directory path; got: {path}")
|
|
44
|
+
self.page_metadata = {}
|
|
49
45
|
|
|
50
|
-
def process_directory(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
46
|
+
def process_directory(self, local_dir: Path) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Recursively scans a directory hierarchy for Markdown files, and processes each, resolving cross-references.
|
|
49
|
+
"""
|
|
54
50
|
|
|
55
51
|
local_dir = local_dir.resolve(True)
|
|
56
|
-
|
|
57
|
-
root_dir = local_dir
|
|
58
|
-
else:
|
|
59
|
-
root_dir = root_dir.resolve(True)
|
|
60
|
-
|
|
61
|
-
LOGGER.info("Synchronizing directory: %s", local_dir)
|
|
52
|
+
LOGGER.info("Processing directory: %s", local_dir)
|
|
62
53
|
|
|
63
54
|
# Step 1: build index of all page metadata
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
LOGGER.info("Indexed %d page(s)", len(page_metadata))
|
|
55
|
+
self._index_directory(local_dir, self.options.root_page_id)
|
|
56
|
+
LOGGER.info("Indexed %d page(s)", len(self.page_metadata))
|
|
67
57
|
|
|
68
58
|
# Step 2: convert each page
|
|
69
|
-
for page_path in page_metadata.keys():
|
|
70
|
-
self._process_page(page_path
|
|
59
|
+
for page_path in self.page_metadata.keys():
|
|
60
|
+
self._process_page(page_path)
|
|
71
61
|
|
|
72
|
-
def process_page(self, path: Path
|
|
73
|
-
"
|
|
62
|
+
def process_page(self, path: Path) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Processes a single Markdown file.
|
|
65
|
+
"""
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
else:
|
|
79
|
-
root_dir = root_dir.resolve(True)
|
|
80
|
-
|
|
81
|
-
self._process_page(path, root_dir, {})
|
|
82
|
-
|
|
83
|
-
def _process_page(
|
|
84
|
-
self,
|
|
85
|
-
path: Path,
|
|
86
|
-
root_dir: Path,
|
|
87
|
-
page_metadata: dict[Path, ConfluencePageMetadata],
|
|
88
|
-
) -> None:
|
|
89
|
-
"Processes a single Markdown file."
|
|
67
|
+
LOGGER.info("Processing page: %s", path)
|
|
68
|
+
self._index_page(path, self.options.root_page_id)
|
|
69
|
+
self._process_page(path)
|
|
90
70
|
|
|
71
|
+
def _process_page(self, path: Path) -> None:
|
|
91
72
|
document = ConfluenceDocument.create(
|
|
92
|
-
path, self.options, root_dir, self.
|
|
73
|
+
path, self.options, self.root_dir, self.site, self.page_metadata
|
|
93
74
|
)
|
|
94
|
-
|
|
95
|
-
with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
|
|
96
|
-
f.write(content)
|
|
75
|
+
self._save_document(document, path)
|
|
97
76
|
|
|
98
|
-
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def _get_or_create_page(
|
|
99
79
|
self,
|
|
100
|
-
|
|
101
|
-
|
|
80
|
+
absolute_path: Path,
|
|
81
|
+
parent_id: Optional[ConfluencePageID],
|
|
82
|
+
*,
|
|
83
|
+
title: Optional[str] = None,
|
|
84
|
+
) -> ConfluencePageMetadata:
|
|
85
|
+
"""
|
|
86
|
+
Creates a new Confluence page if no page is linked in the Markdown document.
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def _save_document(self, document: ConfluenceDocument, path: Path) -> None: ...
|
|
92
|
+
|
|
93
|
+
def _index_directory(
|
|
94
|
+
self, local_dir: Path, parent_id: Optional[ConfluencePageID]
|
|
102
95
|
) -> None:
|
|
103
|
-
"
|
|
96
|
+
"""
|
|
97
|
+
Indexes Markdown files in a directory hierarchy recursively.
|
|
98
|
+
"""
|
|
104
99
|
|
|
105
100
|
LOGGER.info("Indexing directory: %s", local_dir)
|
|
106
101
|
|
|
@@ -117,32 +112,107 @@ class Processor:
|
|
|
117
112
|
elif entry.is_dir():
|
|
118
113
|
directories.append(Path(local_dir) / entry.name)
|
|
119
114
|
|
|
115
|
+
# make page act as parent node
|
|
116
|
+
parent_doc: Optional[Path] = None
|
|
117
|
+
if (Path(local_dir) / "index.md") in files:
|
|
118
|
+
parent_doc = Path(local_dir) / "index.md"
|
|
119
|
+
elif (Path(local_dir) / "README.md") in files:
|
|
120
|
+
parent_doc = Path(local_dir) / "README.md"
|
|
121
|
+
elif (Path(local_dir) / f"{local_dir.name}.md") in files:
|
|
122
|
+
parent_doc = Path(local_dir) / f"{local_dir.name}.md"
|
|
123
|
+
|
|
124
|
+
if parent_doc is None and self.options.keep_hierarchy:
|
|
125
|
+
parent_doc = Path(local_dir) / "index.md"
|
|
126
|
+
|
|
127
|
+
# create a blank page for directory entry
|
|
128
|
+
with open(parent_doc, "w"):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
if parent_doc is not None:
|
|
132
|
+
if parent_doc in files:
|
|
133
|
+
files.remove(parent_doc)
|
|
134
|
+
|
|
135
|
+
# use latest parent as parent for index page
|
|
136
|
+
metadata = self._get_or_create_page(parent_doc, parent_id)
|
|
137
|
+
LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
|
|
138
|
+
self.page_metadata[parent_doc] = metadata
|
|
139
|
+
|
|
140
|
+
# assign new index page as new parent
|
|
141
|
+
parent_id = ConfluencePageID(metadata.page_id)
|
|
142
|
+
|
|
120
143
|
for doc in files:
|
|
121
|
-
|
|
122
|
-
LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
|
|
123
|
-
page_metadata[doc] = metadata
|
|
144
|
+
self._index_page(doc, parent_id)
|
|
124
145
|
|
|
125
146
|
for directory in directories:
|
|
126
|
-
self._index_directory(directory,
|
|
127
|
-
|
|
128
|
-
def
|
|
129
|
-
"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
self._index_directory(directory, parent_id)
|
|
148
|
+
|
|
149
|
+
def _index_page(self, path: Path, parent_id: Optional[ConfluencePageID]) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Indexes a single Markdown file.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
metadata = self._get_or_create_page(path, parent_id)
|
|
155
|
+
LOGGER.debug("Indexed %s with metadata: %s", path, metadata)
|
|
156
|
+
self.page_metadata[path] = metadata
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ProcessorFactory:
|
|
160
|
+
options: ConfluenceDocumentOptions
|
|
161
|
+
site: ConfluenceSiteMetadata
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self, options: ConfluenceDocumentOptions, site: ConfluenceSiteMetadata
|
|
165
|
+
) -> None:
|
|
166
|
+
self.options = options
|
|
167
|
+
self.site = site
|
|
168
|
+
|
|
169
|
+
@abstractmethod
|
|
170
|
+
def create(self, root_dir: Path) -> Processor: ...
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Converter:
|
|
174
|
+
factory: ProcessorFactory
|
|
175
|
+
|
|
176
|
+
def __init__(self, factory: ProcessorFactory) -> None:
|
|
177
|
+
self.factory = factory
|
|
178
|
+
|
|
179
|
+
def process(self, path: Path) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Processes a single Markdown file or a directory of Markdown files.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
path = path.resolve(True)
|
|
185
|
+
if path.is_dir():
|
|
186
|
+
self.process_directory(path)
|
|
187
|
+
elif path.is_file():
|
|
188
|
+
self.process_page(path)
|
|
189
|
+
else:
|
|
190
|
+
raise ArgumentError(f"expected: valid file or directory path; got: {path}")
|
|
191
|
+
|
|
192
|
+
def process_directory(
|
|
193
|
+
self, local_dir: Path, root_dir: Optional[Path] = None
|
|
194
|
+
) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Recursively scans a directory hierarchy for Markdown files, and processes each, resolving cross-references.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
local_dir = local_dir.resolve(True)
|
|
200
|
+
if root_dir is None:
|
|
201
|
+
root_dir = local_dir
|
|
202
|
+
else:
|
|
203
|
+
root_dir = root_dir.resolve(True)
|
|
204
|
+
|
|
205
|
+
self.factory.create(root_dir).process_directory(local_dir)
|
|
206
|
+
|
|
207
|
+
def process_page(self, path: Path, root_dir: Optional[Path] = None) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Processes a single Markdown file.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
path = path.resolve(True)
|
|
213
|
+
if root_dir is None:
|
|
214
|
+
root_dir = path.parent
|
|
215
|
+
else:
|
|
216
|
+
root_dir = root_dir.resolve(True)
|
|
217
|
+
|
|
218
|
+
self.factory.create(root_dir).process_page(path)
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
markdown_to_confluence-0.3.3.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
|
|
2
|
-
md2conf/__init__.py,sha256=NHoSu8tHMVLytWmla4BA_Uzkl-04rV_O8YkkFxUkT_E,402
|
|
3
|
-
md2conf/__main__.py,sha256=aTRiXcvoIYMkwCGejL6MUriHXBo3qVP2Acr2I-XzMyg,7947
|
|
4
|
-
md2conf/api.py,sha256=S5IB7j48wE9MHSj1jodHYmTE6scSXb80faULW6-5RjU,20376
|
|
5
|
-
md2conf/application.py,sha256=FkJ9zYBLwYcCRkd_WiX6JI6nlw4QMETmrOXHeSzCwCE,9735
|
|
6
|
-
md2conf/converter.py,sha256=B4Z8afTmhea6nSXhzDVxN55GfMvlY34tGqCLspQ_p5g,38983
|
|
7
|
-
md2conf/emoji.py,sha256=48QJtOD0F3Be1laYLvAOwe0GxrJS-vcfjtCdiBsNcAc,1960
|
|
8
|
-
md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
|
|
9
|
-
md2conf/matcher.py,sha256=FgMFPvGiOqGezCs8OyerfsVo-iIHFoI6LRMzdcjM5UY,3693
|
|
10
|
-
md2conf/mermaid.py,sha256=un_KHBDpG5Zad_QD3HN1uBwUxp4I-HVJYhNKbH7KwcA,2312
|
|
11
|
-
md2conf/processor.py,sha256=9jPswgPewh2glLSHdgxyXesGxkcxPVa_h7oUhM9EsA4,4740
|
|
12
|
-
md2conf/properties.py,sha256=TOCXLdTfYkKjRwZaMgvXw0mNCI4opEUwpBXro2Kv2B4,2467
|
|
13
|
-
md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
|
|
14
|
-
md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
-
markdown_to_confluence-0.3.3.dist-info/METADATA,sha256=SiOfBvA3jMCn3Hjd_Let9R-DqcMuPG48xP-1x2pg_JI,16495
|
|
16
|
-
markdown_to_confluence-0.3.3.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
|
|
17
|
-
markdown_to_confluence-0.3.3.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
|
|
18
|
-
markdown_to_confluence-0.3.3.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
|
|
19
|
-
markdown_to_confluence-0.3.3.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
20
|
-
markdown_to_confluence-0.3.3.dist-info/RECORD,,
|
{markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{markdown_to_confluence-0.3.3.dist-info → markdown_to_confluence-0.3.4.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|