markdown-to-confluence 0.1.11__py3-none-any.whl → 0.1.12__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: markdown-to-confluence
3
- Version: 0.1.11
3
+ Version: 0.1.12
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,13 +21,13 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.8
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml >=4.9
25
- Requires-Dist: types-lxml >=2023.10.21
26
- Requires-Dist: markdown >=3.5
27
- Requires-Dist: types-markdown >=3.5
28
- Requires-Dist: pymdown-extensions >=10.3
29
- Requires-Dist: requests >=2.31
30
- Requires-Dist: types-requests >=2.31
24
+ Requires-Dist: lxml >=5.2
25
+ Requires-Dist: types-lxml >=2024.4.14
26
+ Requires-Dist: markdown >=3.6
27
+ Requires-Dist: types-markdown >=3.6
28
+ Requires-Dist: pymdown-extensions >=10.8
29
+ Requires-Dist: requests >=2.32
30
+ Requires-Dist: types-requests >=2.32
31
31
 
32
32
  # Publish Markdown files to Confluence wiki
33
33
 
@@ -36,6 +36,7 @@ Contributors to software projects typically write documentation in Markdown form
36
36
  Replicating documentation to Confluence by hand is tedious, and a lack of automated synchronization with the project repositories where the documents live leads to outdated documentation.
37
37
 
38
38
  This Python package
39
+
39
40
  * parses Markdown files,
40
41
  * converts Markdown content into the Confluence Storage Format (XHTML),
41
42
  * invokes Confluence API endpoints to upload images and content.
@@ -54,6 +55,7 @@ This Python package
54
55
  ## Getting started
55
56
 
56
57
  In order to get started, you will need
58
+
57
59
  * your organization domain name (e.g. `instructure.atlassian.net`),
58
60
  * base path for Confluence wiki (typically `/wiki/` for managed Confluence, `/` for on-premise)
59
61
  * your Confluence username (e.g. `levente.hunyadi@instructure.com`) (only if required by your deployment),
@@ -62,7 +64,7 @@ In order to get started, you will need
62
64
 
63
65
  ### Obtaining an API token
64
66
 
65
- 1. Log in to https://id.atlassian.com/manage/api-tokens.
67
+ 1. Log in to <https://id.atlassian.com/manage/api-tokens>.
66
68
  2. Click *Create API token*.
67
69
  3. From the dialog that appears, enter a memorable and concise *Label* for your token and click *Create*.
68
70
  4. Click *Copy to clipboard*, then paste the token to your script, or elsewhere to save.
@@ -70,6 +72,7 @@ In order to get started, you will need
70
72
  ### Setting up the environment
71
73
 
72
74
  Confluence organization domain, base path, username, API token and space key can be specified at runtime or set as Confluence environment variables (e.g. add to your `~/.profile` on Linux, or `~/.bash_profile` or `~/.zshenv` on MacOS):
75
+
73
76
  ```bash
74
77
  export CONFLUENCE_DOMAIN='instructure.atlassian.net'
75
78
  export CONFLUENCE_PATH='/wiki/'
@@ -136,18 +139,18 @@ Use the `--help` switch to get a full list of supported command-line options:
136
139
 
137
140
  ```console
138
141
  $ python3 -m md2conf --help
139
- usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}]
140
- [--generated-by GENERATED_BY] [--no-generated-by] [--ignore-invalid-url]
142
+ usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY]
143
+ [--no-generated-by] [--ignore-invalid-url] [--local]
141
144
  mdpath
142
145
 
143
146
  positional arguments:
144
147
  mdpath Path to Markdown file or directory to convert and publish.
145
148
 
146
- options:
149
+ optional arguments:
147
150
  -h, --help show this help message and exit
148
151
  -d DOMAIN, --domain DOMAIN
149
152
  Confluence organization domain.
150
- -p PATH, --path PATH Base path for Confluece wiki.
153
+ -p PATH, --path PATH Base path for Confluence (default: '/wiki/').
151
154
  -u USERNAME, --username USERNAME
152
155
  Confluence user name.
153
156
  -a APIKEY, --apikey APIKEY
@@ -156,8 +159,10 @@ options:
156
159
  Confluence space key for pages to be published. If omitted, will default to user space.
157
160
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
158
161
  Use this option to set the log verbosity.
162
+ -r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
159
163
  --generated-by GENERATED_BY
160
164
  Add prompt to pages (default: 'This page has been generated with a tool.').
161
165
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
162
166
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
167
+ --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
163
168
  ```
@@ -0,0 +1,16 @@
1
+ md2conf/__init__.py,sha256=SS79zE1Jss2UJQH39HX6zMvAPO2MGjq3jjHny4L6dvY,403
2
+ md2conf/__main__.py,sha256=L0XaHfV3GOPnyceWxUGamZXPfQUL3Dfsf_VpWeF08Qo,4246
3
+ md2conf/api.py,sha256=xYqQbdy-0d_mqv-zDT0DCcLs17HYLdY2LfWoY2jhMB8,14738
4
+ md2conf/application.py,sha256=ksWeDQGQs6xvAS8wdghwBMgWsGI6KMQiwlH5qaKXGv4,5221
5
+ md2conf/converter.py,sha256=xiaf5hQHXPE3psTTk_pHVip4NDfeU-hos3Kkl1Pju50,18236
6
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
+ md2conf/processor.py,sha256=f0JG8jqjsGq2sbxMdHNz6EyZ1KQ92nCKKYU3fxP6C0o,3048
8
+ md2conf/properties.py,sha256=oXvtPssbougM1BTE9ytcD_1Yjc3nd7DDSHqEr0QoZAU,1811
9
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ markdown_to_confluence-0.1.12.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
11
+ markdown_to_confluence-0.1.12.dist-info/METADATA,sha256=HCmOX5YhQuc6v2szBgQa_VuGXaeZNKKBy8Q_Fi1GreQ,7875
12
+ markdown_to_confluence-0.1.12.dist-info/WHEEL,sha256=cpQTJ5IWu9CdaPViMhC9YzF8gZuS5-vlfoFihTBC86A,91
13
+ markdown_to_confluence-0.1.12.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
14
+ markdown_to_confluence-0.1.12.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
15
+ markdown_to_confluence-0.1.12.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
16
+ markdown_to_confluence-0.1.12.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: setuptools (70.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ md2conf = md2conf.__main__:main
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.1.11"
8
+ __version__ = "0.1.12"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2024, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -3,6 +3,7 @@ import logging
3
3
  import os.path
4
4
  import sys
5
5
  import typing
6
+ from pathlib import Path
6
7
  from typing import Optional
7
8
 
8
9
  import requests
@@ -15,7 +16,7 @@ from .properties import ConfluenceProperties
15
16
 
16
17
 
17
18
  class Arguments(argparse.Namespace):
18
- mdpath: str
19
+ mdpath: Path
19
20
  domain: str
20
21
  path: str
21
22
  username: str
@@ -26,104 +27,114 @@ class Arguments(argparse.Namespace):
26
27
  generated_by: Optional[str]
27
28
 
28
29
 
29
- parser = argparse.ArgumentParser()
30
- parser.prog = os.path.basename(os.path.dirname(__file__))
31
- parser.add_argument(
32
- "mdpath", help="Path to Markdown file or directory to convert and publish."
33
- )
34
- parser.add_argument("-d", "--domain", help="Confluence organization domain.")
35
- parser.add_argument("-p", "--path", help="Base path for Confluece wiki.")
36
- parser.add_argument("-u", "--username", help="Confluence user name.")
37
- parser.add_argument(
38
- "-a",
39
- "--apikey",
40
- help="Confluence API key. Refer to documentation how to obtain one.",
41
- )
42
- parser.add_argument(
43
- "-s",
44
- "--space",
45
- help="Confluence space key for pages to be published. If omitted, will default to user space.",
46
- )
47
- parser.add_argument(
48
- "-l",
49
- "--loglevel",
50
- choices=[
51
- typing.cast(str, logging.getLevelName(level)).lower()
52
- for level in (
53
- logging.DEBUG,
54
- logging.INFO,
55
- logging.WARN,
56
- logging.ERROR,
57
- logging.CRITICAL,
58
- )
59
- ],
60
- default=logging.getLevelName(logging.INFO),
61
- help="Use this option to set the log verbosity.",
62
- )
63
- parser.add_argument(
64
- "-r",
65
- dest="root_page",
66
- help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
67
- )
68
- parser.add_argument(
69
- "--generated-by",
70
- default="This page has been generated with a tool.",
71
- help="Add prompt to pages (default: 'This page has been generated with a tool.').",
72
- )
73
- parser.add_argument(
74
- "--no-generated-by",
75
- dest="generated_by",
76
- action="store_const",
77
- const=None,
78
- help="Do not add 'generated by a tool' prompt to pages.",
79
- )
80
- parser.add_argument(
81
- "--ignore-invalid-url",
82
- action="store_true",
83
- default=False,
84
- help="Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.",
85
- )
86
- parser.add_argument(
87
- "--local",
88
- action="store_true",
89
- default=False,
90
- help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
91
- )
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser()
32
+ parser.prog = os.path.basename(os.path.dirname(__file__))
33
+ parser.add_argument(
34
+ "mdpath", help="Path to Markdown file or directory to convert and publish."
35
+ )
36
+ parser.add_argument("-d", "--domain", help="Confluence organization domain.")
37
+ parser.add_argument(
38
+ "-p", "--path", help="Base path for Confluence (default: '/wiki/')."
39
+ )
40
+ parser.add_argument("-u", "--username", help="Confluence user name.")
41
+ parser.add_argument(
42
+ "-a",
43
+ "--apikey",
44
+ help="Confluence API key. Refer to documentation how to obtain one.",
45
+ )
46
+ parser.add_argument(
47
+ "-s",
48
+ "--space",
49
+ help="Confluence space key for pages to be published. If omitted, will default to user space.",
50
+ )
51
+ parser.add_argument(
52
+ "-l",
53
+ "--loglevel",
54
+ choices=[
55
+ typing.cast(str, logging.getLevelName(level)).lower()
56
+ for level in (
57
+ logging.DEBUG,
58
+ logging.INFO,
59
+ logging.WARN,
60
+ logging.ERROR,
61
+ logging.CRITICAL,
62
+ )
63
+ ],
64
+ default=logging.getLevelName(logging.INFO),
65
+ help="Use this option to set the log verbosity.",
66
+ )
67
+ parser.add_argument(
68
+ "-r",
69
+ dest="root_page",
70
+ help="Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.",
71
+ )
72
+ parser.add_argument(
73
+ "--generated-by",
74
+ default="This page has been generated with a tool.",
75
+ help="Add prompt to pages (default: 'This page has been generated with a tool.').",
76
+ )
77
+ parser.add_argument(
78
+ "--no-generated-by",
79
+ dest="generated_by",
80
+ action="store_const",
81
+ const=None,
82
+ help="Do not add 'generated by a tool' prompt to pages.",
83
+ )
84
+ parser.add_argument(
85
+ "--ignore-invalid-url",
86
+ action="store_true",
87
+ default=False,
88
+ help="Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.",
89
+ )
90
+ parser.add_argument(
91
+ "--local",
92
+ action="store_true",
93
+ default=False,
94
+ help="Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.",
95
+ )
92
96
 
93
- args = Arguments()
94
- parser.parse_args(namespace=args)
97
+ args = Arguments()
98
+ parser.parse_args(namespace=args)
95
99
 
96
- logging.basicConfig(
97
- level=getattr(logging, args.loglevel.upper(), logging.INFO),
98
- format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
99
- )
100
+ # NOTE: If we switch to modern type aware CLI tool like typer
101
+ # the following line won't be necessary
102
+ args.mdpath = Path(args.mdpath)
100
103
 
104
+ logging.basicConfig(
105
+ level=getattr(logging, args.loglevel.upper(), logging.INFO),
106
+ format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
107
+ )
101
108
 
102
- options = ConfluenceDocumentOptions(
103
- ignore_invalid_url=args.ignore_invalid_url,
104
- generated_by=args.generated_by,
105
- root_page_id=args.root_page,
106
- )
107
- properties = ConfluenceProperties(
108
- args.domain, args.path, args.username, args.apikey, args.space
109
- )
110
- if args.local:
111
- Processor(options, properties).process(args.mdpath)
112
- else:
113
- try:
114
- with ConfluenceAPI(properties) as api:
115
- Application(
116
- api,
117
- options,
118
- ).synchronize(args.mdpath)
119
- except requests.exceptions.HTTPError as err:
120
- logging.error(err)
109
+ options = ConfluenceDocumentOptions(
110
+ ignore_invalid_url=args.ignore_invalid_url,
111
+ generated_by=args.generated_by,
112
+ root_page_id=args.root_page,
113
+ )
114
+ properties = ConfluenceProperties(
115
+ args.domain, args.path, args.username, args.apikey, args.space
116
+ )
117
+ if args.local:
118
+ Processor(options, properties).process(args.mdpath)
119
+ else:
120
+ try:
121
+ with ConfluenceAPI(properties) as api:
122
+ Application(
123
+ api,
124
+ options,
125
+ ).synchronize(args.mdpath)
126
+ except requests.exceptions.HTTPError as err:
127
+ logging.error(err)
121
128
 
122
- # print details for a response with JSON body
123
- if err.response is not None:
124
- try:
125
- logging.error(err.response.json())
126
- except requests.exceptions.JSONDecodeError:
127
- pass
129
+ # print details for a response with JSON body
130
+ if err.response is not None:
131
+ try:
132
+ logging.error(err.response.json())
133
+ except requests.exceptions.JSONDecodeError:
134
+ pass
128
135
 
129
- sys.exit(1)
136
+ sys.exit(1)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
md2conf/api.py CHANGED
@@ -1,12 +1,11 @@
1
1
  import json
2
2
  import logging
3
3
  import mimetypes
4
- import os
5
- import os.path
6
4
  import sys
7
5
  import typing
8
6
  from contextlib import contextmanager
9
7
  from dataclasses import dataclass
8
+ from pathlib import Path
10
9
  from types import TracebackType
11
10
  from typing import Dict, Generator, List, Optional, Type, Union
12
11
  from urllib.parse import urlencode, urlparse, urlunparse
@@ -184,15 +183,16 @@ class ConfluenceSession:
184
183
  def upload_attachment(
185
184
  self,
186
185
  page_id: str,
187
- attachment_path: str,
186
+ attachment_path: Path,
188
187
  attachment_name: str,
189
188
  comment: Optional[str] = None,
190
189
  *,
191
190
  space_key: Optional[str] = None,
191
+ force: bool = False,
192
192
  ) -> None:
193
193
  content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
194
194
 
195
- if not os.path.isfile(attachment_path):
195
+ if not attachment_path.is_file():
196
196
  raise ConfluenceError(f"file not found: {attachment_path}")
197
197
 
198
198
  try:
@@ -200,7 +200,7 @@ class ConfluenceSession:
200
200
  page_id, attachment_name, space_key=space_key
201
201
  )
202
202
 
203
- if attachment.file_size == os.path.getsize(attachment_path):
203
+ if not force and attachment.file_size == attachment_path.stat().st_size:
204
204
  LOGGER.info("Up-to-date attachment: %s", attachment_name)
205
205
  return
206
206
 
md2conf/application.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os.path
3
+ from pathlib import Path
3
4
  from typing import Dict, Optional
4
5
 
5
6
  from .api import ConfluenceSession
@@ -7,6 +8,7 @@ from .converter import (
7
8
  ConfluenceDocument,
8
9
  ConfluenceDocumentOptions,
9
10
  ConfluencePageMetadata,
11
+ attachment_name,
10
12
  extract_qualified_id,
11
13
  )
12
14
 
@@ -25,40 +27,42 @@ class Application:
25
27
  self.api = api
26
28
  self.options = options
27
29
 
28
- def synchronize(self, path: str) -> None:
30
+ def synchronize(self, path: Path) -> None:
29
31
  "Synchronizes a single Markdown page or a directory of Markdown pages."
30
32
 
31
- if os.path.isdir(path):
33
+ if path.is_dir():
32
34
  self.synchronize_directory(path)
33
- elif os.path.isfile(path):
35
+ elif path.is_file():
34
36
  self.synchronize_page(path)
35
37
  else:
36
38
  raise ValueError(f"expected: valid file or directory path; got: {path}")
37
39
 
38
- def synchronize_page(self, page_path: str) -> None:
40
+ def synchronize_page(self, page_path: Path) -> None:
39
41
  "Synchronizes a single Markdown page with Confluence."
40
42
 
41
43
  self._synchronize_page(page_path, {})
42
44
 
43
- def synchronize_directory(self, dir: str) -> None:
45
+ def synchronize_directory(self, local_dir: Path) -> None:
44
46
  "Synchronizes a directory of Markdown pages with Confluence."
45
47
 
46
- page_metadata: Dict[str, ConfluencePageMetadata] = {}
47
- LOGGER.info(f"Synchronizing directory: {dir}")
48
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
49
+ LOGGER.info(f"Synchronizing directory: {local_dir}")
48
50
 
49
51
  # Step 1: build index of all page metadata
50
- for root, directories, files in os.walk(dir):
52
+ # NOTE: Pathlib.walk() is implemented only in Python 3.12+
53
+ # so sticking for old os.walk
54
+ for root, directories, files in os.walk(local_dir):
51
55
  for file_name in files:
52
- # check the file extension
53
- _, file_extension = os.path.splitext(file_name)
54
- if file_extension.lower() != ".md":
55
- continue
56
+ # Reconstitute Path object back
57
+ docfile = (Path(root) / file_name).absolute()
56
58
 
57
- absolute_path = os.path.join(os.path.abspath(root), file_name)
58
- metadata = self._get_or_create_page(absolute_path)
59
+ # Skip non-markdown files
60
+ if docfile.suffix.lower() != ".md":
61
+ continue
62
+ metadata = self._get_or_create_page(docfile)
59
63
 
60
- LOGGER.debug(f"indexed {absolute_path} with metadata: {metadata}")
61
- page_metadata[absolute_path] = metadata
64
+ LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
65
+ page_metadata[docfile] = metadata
62
66
 
63
67
  LOGGER.info(f"indexed {len(page_metadata)} pages")
64
68
 
@@ -68,10 +72,10 @@ class Application:
68
72
 
69
73
  def _synchronize_page(
70
74
  self,
71
- page_path: str,
72
- page_metadata: Dict[str, ConfluencePageMetadata],
75
+ page_path: Path,
76
+ page_metadata: Dict[Path, ConfluencePageMetadata],
73
77
  ) -> None:
74
- base_path = os.path.dirname(page_path)
78
+ base_path = page_path.parent
75
79
 
76
80
  LOGGER.info(f"Synchronizing page: {page_path}")
77
81
  document = ConfluenceDocument(page_path, self.options, page_metadata)
@@ -83,7 +87,7 @@ class Application:
83
87
  self._update_document(document, base_path)
84
88
 
85
89
  def _get_or_create_page(
86
- self, absolute_path: str, title: Optional[str] = None
90
+ self, absolute_path: Path, title: Optional[str] = None
87
91
  ) -> ConfluencePageMetadata:
88
92
  """
89
93
  Creates a new Confluence page if no page is linked in the Markdown document.
@@ -106,7 +110,7 @@ class Application:
106
110
 
107
111
  # use file name without extension if no title is supplied
108
112
  if title is None:
109
- title, _ = os.path.splitext(os.path.basename(absolute_path))
113
+ title = absolute_path.stem
110
114
 
111
115
  confluence_page = self.api.get_or_create_page(
112
116
  title, self.options.root_page_id
@@ -126,10 +130,10 @@ class Application:
126
130
  title=confluence_page.title or "",
127
131
  )
128
132
 
129
- def _update_document(self, document: ConfluenceDocument, base_path: str) -> None:
133
+ def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
130
134
  for image in document.images:
131
135
  self.api.upload_attachment(
132
- document.id.page_id, os.path.join(base_path, image), image, ""
136
+ document.id.page_id, base_path / image, attachment_name(image), ""
133
137
  )
134
138
 
135
139
  content = document.xhtml()
@@ -138,7 +142,7 @@ class Application:
138
142
 
139
143
  def _update_markdown(
140
144
  self,
141
- path: str,
145
+ path: Path,
142
146
  document: str,
143
147
  page_id: str,
144
148
  space_key: Optional[str],
md2conf/converter.py CHANGED
@@ -207,11 +207,6 @@ class NodeVisitor:
207
207
  pass
208
208
 
209
209
 
210
- def _change_ext(path: str, target_ext: str) -> str:
211
- root, source_ext = os.path.splitext(path)
212
- return f"{root}{target_ext}"
213
-
214
-
215
210
  @dataclass
216
211
  class ConfluenceConverterOptions:
217
212
  """
@@ -228,22 +223,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
228
223
  "Transforms a plain HTML tree into the Confluence storage format."
229
224
 
230
225
  options: ConfluenceConverterOptions
231
- path: str
232
- base_path: str
226
+ path: pathlib.Path
227
+ base_path: pathlib.Path
233
228
  links: List[str]
234
229
  images: List[str]
235
- page_metadata: Dict[str, ConfluencePageMetadata]
230
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
236
231
 
237
232
  def __init__(
238
233
  self,
239
234
  options: ConfluenceConverterOptions,
240
- path: str,
241
- page_metadata: Dict[str, ConfluencePageMetadata],
235
+ path: pathlib.Path,
236
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
242
237
  ) -> None:
243
238
  super().__init__()
244
239
  self.options = options
245
240
  self.path = path
246
- self.base_path = os.path.abspath(os.path.dirname(path)) + os.sep
241
+ self.base_path = path.parent
247
242
  self.links = []
248
243
  self.images = []
249
244
  self.page_metadata = page_metadata
@@ -270,9 +265,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
270
265
  # convert the relative URL to absolute URL based on the base path value, then look up
271
266
  # the absolute path in the page metadata dictionary to discover the relative path
272
267
  # within Confluence that should be used
273
- absolute_path = os.path.abspath(os.path.join(self.base_path, relative_url.path))
274
- if not absolute_path.startswith(self.base_path):
275
- msg = f"relative URL points to outside base path: {url}"
268
+ absolute_path = (self.base_path / relative_url.path).absolute()
269
+ if not str(absolute_path).startswith(str(self.base_path)):
270
+ msg = f"relative URL {url} points to outside base path: {self.base_path}"
276
271
  if self.options.ignore_invalid_url:
277
272
  LOGGER.warning(msg)
278
273
  anchor.attrib.pop("href")
@@ -314,10 +309,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
314
309
  path: str = image.attrib["src"]
315
310
 
316
311
  # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
317
- if path and is_relative_url(path) and path.endswith(".svg"):
318
- replacement_path = _change_ext(path, ".png")
319
- if os.path.exists(os.path.join(self.base_path, replacement_path)):
320
- path = replacement_path
312
+ if path and is_relative_url(path):
313
+ relative_path = pathlib.Path(path)
314
+ if (
315
+ relative_path.suffix == ".svg"
316
+ and (self.base_path / relative_path.with_suffix(".png")).exists()
317
+ ):
318
+ path = str(relative_path.with_suffix(".png"))
321
319
 
322
320
  self.images.append(path)
323
321
  caption = image.attrib["alt"]
@@ -327,7 +325,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
327
325
  ET.QName(namespaces["ac"], "align"): "center",
328
326
  ET.QName(namespaces["ac"], "layout"): "center",
329
327
  },
330
- RI("attachment", {ET.QName(namespaces["ri"], "filename"): path}),
328
+ RI(
329
+ "attachment",
330
+ {ET.QName(namespaces["ri"], "filename"): attachment_name(path)},
331
+ ),
331
332
  AC("caption", HTML.p(caption)),
332
333
  )
333
334
 
@@ -391,6 +392,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
391
392
  if class_name is None:
392
393
  raise DocumentError(f"unsupported admonition label: {class_list}")
393
394
 
395
+ for e in elem:
396
+ self.visit(e)
397
+
394
398
  # <p class="admonition-title">Note</p>
395
399
  if "admonition-title" in elem[0].attrib.get("class", "").split(" "):
396
400
  content = [
@@ -530,27 +534,33 @@ class ConfluenceDocument:
530
534
 
531
535
  def __init__(
532
536
  self,
533
- path: str,
537
+ path: pathlib.Path,
534
538
  options: ConfluenceDocumentOptions,
535
- page_metadata: Dict[str, ConfluencePageMetadata],
539
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
536
540
  ) -> None:
537
541
  self.options = options
538
- path = os.path.abspath(path)
542
+ path = path.absolute()
539
543
 
540
544
  with open(path, "r") as f:
541
- html = markdown_to_html(f.read())
545
+ text = f.read()
542
546
 
543
547
  # extract Confluence page ID
544
- qualified_id, html = extract_qualified_id(html)
548
+ qualified_id, text = extract_qualified_id(text)
545
549
  if qualified_id is None:
546
550
  raise ValueError("missing Confluence page ID")
547
551
  self.id = qualified_id
548
552
 
549
553
  # extract 'generated-by' tag text
550
- generated_by_tag, html = extract_value(
551
- r"<!--\s+generated-by:\s*(.*)\s+-->", html
554
+ generated_by_tag, text = extract_value(
555
+ r"<!--\s+generated-by:\s*(.*)\s+-->", text
552
556
  )
553
557
 
558
+ # extract frontmatter
559
+ frontmatter, text = extract_value(r"(?ms)\A---$(.+?)^---$", text)
560
+
561
+ # convert to HTML
562
+ html = markdown_to_html(text)
563
+
554
564
  # parse Markdown document
555
565
  if self.options.generated_by is not None:
556
566
  generated_by = self.options.generated_by
@@ -582,6 +592,18 @@ class ConfluenceDocument:
582
592
  return _content_to_string(self.root)
583
593
 
584
594
 
595
+ def attachment_name(name: str) -> str:
596
+ """
597
+ Safe name for use with attachment uploads.
598
+
599
+ Allowed characters:
600
+ * Alphanumeric characters: 0-9, a-z, A-Z
601
+ * Special characters: hyphen (-), underscore (_), period (.)
602
+ """
603
+
604
+ return re.sub(r"[^\-0-9A-Za-z_.]", "_", name)
605
+
606
+
585
607
  def sanitize_confluence(html: str) -> str:
586
608
  "Generates a sanitized version of a Confluence storage format XHTML document with no volatile attributes."
587
609
 
md2conf/processor.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import logging
2
- import os.path
2
+ import os
3
+ from pathlib import Path
3
4
  from typing import Dict
4
5
 
5
6
  from .converter import (
@@ -23,35 +24,37 @@ class Processor:
23
24
  self.options = options
24
25
  self.properties = properties
25
26
 
26
- def process(self, path: str) -> None:
27
+ def process(self, path: Path) -> None:
27
28
  "Processes a single Markdown file or a directory of Markdown files."
28
29
 
29
- if os.path.isdir(path):
30
+ if path.is_dir():
30
31
  self.process_directory(path)
31
- elif os.path.isfile(path):
32
+ elif path.is_file():
32
33
  self.process_page(path, {})
33
34
  else:
34
35
  raise ValueError(f"expected: valid file or directory path; got: {path}")
35
36
 
36
- def process_directory(self, dir: str) -> None:
37
+ def process_directory(self, local_dir: Path) -> None:
37
38
  "Recursively scans a directory hierarchy for Markdown files."
38
39
 
39
- page_metadata: Dict[str, ConfluencePageMetadata] = {}
40
- LOGGER.info(f"Synchronizing directory: {dir}")
40
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
41
+ LOGGER.info(f"Synchronizing directory: {local_dir}")
41
42
 
42
43
  # Step 1: build index of all page metadata
43
- for root, directories, files in os.walk(dir):
44
+ # NOTE: Pathlib.walk() is implemented only in Python 3.12+
45
+ # so sticking for old os.walk
46
+ for root, directories, files in os.walk(local_dir):
44
47
  for file_name in files:
45
- # check the file extension
46
- _, file_extension = os.path.splitext(file_name)
47
- if file_extension.lower() != ".md":
48
- continue
48
+ # Reconstitute Path object back
49
+ docfile = (Path(root) / file_name).absolute()
49
50
 
50
- absolute_path = os.path.join(os.path.abspath(root), file_name)
51
- metadata = self._get_page(absolute_path)
51
+ # Skip non-markdown files
52
+ if docfile.suffix.lower() != ".md":
53
+ continue
52
54
 
53
- LOGGER.debug(f"indexed {absolute_path} with metadata: {metadata}")
54
- page_metadata[absolute_path] = metadata
55
+ metadata = self._get_page(docfile)
56
+ LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
57
+ page_metadata[docfile] = metadata
55
58
 
56
59
  LOGGER.info(f"indexed {len(page_metadata)} pages")
57
60
 
@@ -60,17 +63,16 @@ class Processor:
60
63
  self.process_page(page_path, page_metadata)
61
64
 
62
65
  def process_page(
63
- self, path: str, page_metadata: Dict[str, ConfluencePageMetadata]
66
+ self, path: Path, page_metadata: Dict[Path, ConfluencePageMetadata]
64
67
  ) -> None:
65
68
  "Processes a single Markdown file."
66
69
 
67
70
  document = ConfluenceDocument(path, self.options, page_metadata)
68
71
  content = document.xhtml()
69
- output_path, _ = os.path.splitext(path)
70
- with open(f"{output_path}.csf", "w") as f:
72
+ with open(path.with_suffix(".csf"), "w") as f:
71
73
  f.write(content)
72
74
 
73
- def _get_page(self, absolute_path: str) -> ConfluencePageMetadata:
75
+ def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
74
76
  "Extracts metadata from a Markdown file."
75
77
 
76
78
  with open(absolute_path, "r") as f:
md2conf/properties.py CHANGED
@@ -1,5 +1,4 @@
1
1
  import os
2
- import os.path
3
2
  from typing import Optional
4
3
 
5
4
 
@@ -1,15 +0,0 @@
1
- md2conf/__init__.py,sha256=YKxQYqSyS46CCTga2WXYIsXcR7QG-GGWn3t1RhzjveE,403
2
- md2conf/__main__.py,sha256=g46ly26Otzfqve5lCVLoKdRdaOuEsx0of831swWE01o,3602
3
- md2conf/api.py,sha256=2xEUB8RgXGWxH8I-dGiAPH-1a_m3MBHxqJhOpT5P4A0,14702
4
- md2conf/application.py,sha256=nPzygYLZNW6YfPA9NC3qAznHQRrvTUndHiNqW24N3YU,5147
5
- md2conf/converter.py,sha256=yCcnFTkunqu3KkUJTLeNIHqz4CTAzAem-wGrGwVLR5k,17714
6
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
- md2conf/processor.py,sha256=nmrTlZhovHkTndFAVYMXm7owXfYlQf7-PyZUGDmE6PE,3007
8
- md2conf/properties.py,sha256=3KMsJhF-O2qJW-ew7ffpPyuVaZ58c24a6ZRP0HmiZ3o,1826
9
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- markdown_to_confluence-0.1.11.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
11
- markdown_to_confluence-0.1.11.dist-info/METADATA,sha256=5KhaUfxu1yYQoIk289E-y9Q5_hkHofhht0JFMboQZTc,7580
12
- markdown_to_confluence-0.1.11.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
13
- markdown_to_confluence-0.1.11.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
14
- markdown_to_confluence-0.1.11.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
15
- markdown_to_confluence-0.1.11.dist-info/RECORD,,