markdown-to-confluence 0.1.10__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
  MIT License
2
2
 
3
- Copyright (c) 2022-2023 Levente Hunyadi
3
+ Copyright (c) 2022-2024 Levente Hunyadi
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: markdown-to-confluence
3
- Version: 0.1.10
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,28 +139,30 @@ 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]
140
- [-l {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [--generated-by GENERATED_BY] [--no-generated-by]
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
154
157
  Confluence API key. Refer to documentation how to obtain one.
155
158
  -s SPACE, --space SPACE
156
- Confluence space key for pages to be published. If omitted, will default to user
157
- space.
158
- -l {DEBUG,INFO,WARNING,ERROR,CRITICAL}, --loglevel {DEBUG,INFO,WARNING,ERROR,CRITICAL}
159
+ Confluence space key for pages to be published. If omitted, will default to user space.
160
+ -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
159
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.
160
163
  --generated-by GENERATED_BY
161
164
  Add prompt to pages (default: 'This page has been generated with a tool.').
162
165
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
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.41.3)
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,9 +5,9 @@ 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.10"
8
+ __version__ = "0.1.12"
9
9
  __author__ = "Levente Hunyadi"
10
- __copyright__ = "Copyright 2022-2023, Levente Hunyadi"
10
+ __copyright__ = "Copyright 2022-2024, Levente Hunyadi"
11
11
  __license__ = "MIT"
12
12
  __maintainer__ = "Levente Hunyadi"
13
13
  __status__ = "Production"
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
@@ -10,89 +11,130 @@ import requests
10
11
  from .api import ConfluenceAPI
11
12
  from .application import Application
12
13
  from .converter import ConfluenceDocumentOptions
14
+ from .processor import Processor
15
+ from .properties import ConfluenceProperties
13
16
 
14
17
 
15
18
  class Arguments(argparse.Namespace):
16
- mdpath: str
19
+ mdpath: Path
17
20
  domain: str
18
21
  path: str
19
22
  username: str
20
23
  apikey: str
21
24
  space: str
22
25
  loglevel: str
26
+ ignore_invalid_url: bool
23
27
  generated_by: Optional[str]
24
28
 
25
29
 
26
- parser = argparse.ArgumentParser()
27
- parser.prog = os.path.basename(os.path.dirname(__file__))
28
- parser.add_argument(
29
- "mdpath", help="Path to Markdown file or directory to convert and publish."
30
- )
31
- parser.add_argument("-d", "--domain", help="Confluence organization domain.")
32
- parser.add_argument("-p", "--path", help="Base path for Confluece wiki.")
33
- parser.add_argument("-u", "--username", help="Confluence user name.")
34
- parser.add_argument(
35
- "-a",
36
- "--apikey",
37
- help="Confluence API key. Refer to documentation how to obtain one.",
38
- )
39
- parser.add_argument(
40
- "-s",
41
- "--space",
42
- help="Confluence space key for pages to be published. If omitted, will default to user space.",
43
- )
44
- parser.add_argument(
45
- "-l",
46
- "--loglevel",
47
- choices=[
48
- typing.cast(str, logging.getLevelName(level)).lower()
49
- for level in (
50
- logging.DEBUG,
51
- logging.INFO,
52
- logging.WARN,
53
- logging.ERROR,
54
- logging.CRITICAL,
55
- )
56
- ],
57
- default=logging.getLevelName(logging.INFO),
58
- help="Use this option to set the log verbosity.",
59
- )
60
- parser.add_argument(
61
- "--generated-by",
62
- default="This page has been generated with a tool.",
63
- help="Add prompt to pages (default: 'This page has been generated with a tool.').",
64
- )
65
- parser.add_argument(
66
- "--no-generated-by",
67
- dest="generated_by",
68
- action="store_const",
69
- const=None,
70
- help="Do not add 'generated by a tool' prompt to pages.",
71
- )
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
+ )
72
96
 
73
- args = Arguments()
74
- parser.parse_args(namespace=args)
97
+ args = Arguments()
98
+ parser.parse_args(namespace=args)
75
99
 
76
- logging.basicConfig(
77
- level=getattr(logging, args.loglevel.upper(), logging.INFO),
78
- format="%(asctime)s - %(levelname)s - %(funcName)s [%(lineno)d] - %(message)s",
79
- )
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)
80
103
 
81
- try:
82
- with ConfluenceAPI(
83
- args.domain, args.path, args.username, args.apikey, args.space
84
- ) as api:
85
- Application(api, ConfluenceDocumentOptions(args.generated_by)).synchronize(
86
- args.mdpath
87
- )
88
- except requests.exceptions.HTTPError as err:
89
- logging.error(err)
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
+ )
90
108
 
91
- # print details for a response with JSON body
92
- if err.response is not None:
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:
93
120
  try:
94
- logging.error(err.response.json())
95
- except requests.exceptions.JSONDecodeError:
96
- pass
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)
128
+
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
135
+
136
+ sys.exit(1)
137
+
97
138
 
98
- sys.exit(1)
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
@@ -14,6 +13,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
14
13
  import requests
15
14
 
16
15
  from .converter import ParseError, sanitize_confluence
16
+ from .properties import ConfluenceError, ConfluenceProperties
17
17
 
18
18
  # a JSON type with possible `null` values
19
19
  JsonType = Union[
@@ -56,7 +56,8 @@ else:
56
56
  "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
57
57
 
58
58
  if string.startswith(prefix):
59
- return string[len(prefix) :]
59
+ prefix_len = len(prefix)
60
+ return string[prefix_len:]
60
61
  else:
61
62
  return string
62
63
 
@@ -64,10 +65,6 @@ else:
64
65
  LOGGER = logging.getLogger(__name__)
65
66
 
66
67
 
67
- class ConfluenceError(RuntimeError):
68
- pass
69
-
70
-
71
68
  @dataclass
72
69
  class ConfluenceAttachment:
73
70
  id: str
@@ -79,64 +76,32 @@ class ConfluenceAttachment:
79
76
  @dataclass
80
77
  class ConfluencePage:
81
78
  id: str
79
+ space_key: str
82
80
  title: str
83
81
  version: int
84
82
  content: str
85
83
 
86
84
 
87
85
  class ConfluenceAPI:
88
- domain: str
89
- base_path: str
90
- space_key: str
91
- user_name: Optional[str]
92
- api_key: str
93
-
86
+ properties: ConfluenceProperties
94
87
  session: Optional["ConfluenceSession"] = None
95
88
 
96
- def __init__(
97
- self,
98
- domain: Optional[str] = None,
99
- base_path: Optional[str] = None,
100
- user_name: Optional[str] = None,
101
- api_key: Optional[str] = None,
102
- space_key: Optional[str] = None,
103
- ) -> None:
104
- opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
105
- opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
106
- opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
107
- opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
108
- opt_space_key = space_key or os.getenv("CONFLUENCE_SPACE_KEY")
109
-
110
- if not opt_domain:
111
- raise ConfluenceError("Confluence domain not specified")
112
- if not opt_base_path:
113
- opt_base_path = "/wiki/"
114
- if not opt_api_key:
115
- raise ConfluenceError("Confluence API key not specified")
116
- if not opt_space_key:
117
- raise ConfluenceError("Confluence space key not specified")
118
-
119
- if opt_domain.startswith(("http://", "https://")) or opt_domain.endswith("/"):
120
- raise ConfluenceError(
121
- "Confluence domain looks like a URL; only host name required"
122
- )
123
- if not opt_base_path.startswith("/") or not opt_base_path.endswith("/"):
124
- raise ConfluenceError("Confluence base path must start and end with a '/'")
125
-
126
- self.domain = opt_domain
127
- self.base_path = opt_base_path
128
- self.user_name = opt_user_name
129
- self.api_key = opt_api_key
130
- self.space_key = opt_space_key
89
+ def __init__(self, properties: Optional[ConfluenceProperties] = None) -> None:
90
+ self.properties = properties or ConfluenceProperties()
131
91
 
132
92
  def __enter__(self) -> "ConfluenceSession":
133
93
  session = requests.Session()
134
- if self.user_name:
135
- session.auth = (self.user_name, self.api_key)
94
+ if self.properties.user_name:
95
+ session.auth = (self.properties.user_name, self.properties.api_key)
136
96
  else:
137
- session.headers.update({"Authorization": f"Bearer {self.api_key}"})
97
+ session.headers.update(
98
+ {"Authorization": f"Bearer {self.properties.api_key}"}
99
+ )
138
100
  self.session = ConfluenceSession(
139
- session, self.domain, self.base_path, self.space_key
101
+ session,
102
+ self.properties.domain,
103
+ self.properties.base_path,
104
+ self.properties.space_key,
140
105
  )
141
106
  return self.session
142
107
 
@@ -197,10 +162,10 @@ class ConfluenceSession:
197
162
  response.raise_for_status()
198
163
 
199
164
  def get_attachment_by_name(
200
- self, page_id: str, filename: str
165
+ self, page_id: str, filename: str, *, space_key: Optional[str] = None
201
166
  ) -> ConfluenceAttachment:
202
167
  path = f"/content/{page_id}/child/attachment"
203
- query = {"spaceKey": self.space_key, "filename": filename}
168
+ query = {"spaceKey": space_key or self.space_key, "filename": filename}
204
169
  data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
205
170
 
206
171
  results = typing.cast(List[JsonType], data["results"])
@@ -218,19 +183,24 @@ class ConfluenceSession:
218
183
  def upload_attachment(
219
184
  self,
220
185
  page_id: str,
221
- attachment_path: str,
186
+ attachment_path: Path,
222
187
  attachment_name: str,
223
188
  comment: Optional[str] = None,
189
+ *,
190
+ space_key: Optional[str] = None,
191
+ force: bool = False,
224
192
  ) -> None:
225
193
  content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
226
194
 
227
- if not os.path.isfile(attachment_path):
195
+ if not attachment_path.is_file():
228
196
  raise ConfluenceError(f"file not found: {attachment_path}")
229
197
 
230
198
  try:
231
- attachment = self.get_attachment_by_name(page_id, attachment_name)
199
+ attachment = self.get_attachment_by_name(
200
+ page_id, attachment_name, space_key=space_key
201
+ )
232
202
 
233
- if attachment.file_size == os.path.getsize(attachment_path):
203
+ if not force and attachment.file_size == attachment_path.stat().st_size:
234
204
  LOGGER.info("Up-to-date attachment: %s", attachment_name)
235
205
  return
236
206
 
@@ -271,10 +241,18 @@ class ConfluenceSession:
271
241
  version = result["version"]["number"] + 1
272
242
 
273
243
  # ensure path component is retained in attachment name
274
- self._update_attachment(page_id, attachment_id, version, attachment_name)
244
+ self._update_attachment(
245
+ page_id, attachment_id, version, attachment_name, space_key=space_key
246
+ )
275
247
 
276
248
  def _update_attachment(
277
- self, page_id: str, attachment_id: str, version: int, attachment_title: str
249
+ self,
250
+ page_id: str,
251
+ attachment_id: str,
252
+ version: int,
253
+ attachment_title: str,
254
+ *,
255
+ space_key: Optional[str] = None,
278
256
  ) -> None:
279
257
  id = removeprefix(attachment_id, "att")
280
258
  path = f"/content/{page_id}/child/attachment/{id}"
@@ -283,24 +261,30 @@ class ConfluenceSession:
283
261
  "type": "attachment",
284
262
  "status": "current",
285
263
  "title": attachment_title,
286
- "space": {"key": self.space_key},
264
+ "space": {"key": space_key or self.space_key},
287
265
  "version": {"minorEdit": True, "number": version},
288
266
  }
289
267
 
290
268
  LOGGER.info("Updating attachment: %s", attachment_id)
291
269
  self._save(path, data)
292
270
 
293
- def get_page_id_by_title(self, title: str) -> str:
271
+ def get_page_id_by_title(
272
+ self,
273
+ title: str,
274
+ *,
275
+ space_key: Optional[str] = None,
276
+ ) -> str:
294
277
  """
295
- Retrieve a Confluence wiki page details by title.
278
+ Look up a Confluence wiki page ID by title.
296
279
 
297
280
  :param title: The page title.
298
- :returns: Confluence page info.
281
+ :param space_key: The Confluence space key (unless the default space is to be used).
282
+ :returns: Confluence page ID.
299
283
  """
300
284
 
301
285
  LOGGER.info("Looking up page with title: %s", title)
302
286
  path = "/content"
303
- query = {"title": title, "spaceKey": self.space_key}
287
+ query = {"title": title, "spaceKey": space_key or self.space_key}
304
288
  data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
305
289
 
306
290
  results = typing.cast(List[JsonType], data["results"])
@@ -311,12 +295,23 @@ class ConfluenceSession:
311
295
  id = typing.cast(str, result["id"])
312
296
  return id
313
297
 
314
- def get_page(self, page_id: str) -> ConfluencePage:
298
+ def get_page(
299
+ self, page_id: str, *, space_key: Optional[str] = None
300
+ ) -> ConfluencePage:
301
+ """
302
+ Retrieve Confluence wiki page details.
303
+
304
+ :param page_id: The Confluence page ID.
305
+ :param space_key: The Confluence space key (unless the default space is to be used).
306
+ :returns: Confluence page info.
307
+ """
308
+
315
309
  path = f"/content/{page_id}"
316
310
  query = {
317
- "spaceKey": self.space_key,
311
+ "spaceKey": space_key or self.space_key,
318
312
  "expand": "body.storage,version",
319
313
  }
314
+
320
315
  data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
321
316
  version = typing.cast(Dict[str, JsonType], data["version"])
322
317
  body = typing.cast(Dict[str, JsonType], data["body"])
@@ -324,23 +319,43 @@ class ConfluenceSession:
324
319
 
325
320
  return ConfluencePage(
326
321
  id=page_id,
322
+ space_key=space_key or self.space_key,
327
323
  title=typing.cast(str, data["title"]),
328
324
  version=typing.cast(int, version["number"]),
329
325
  content=typing.cast(str, storage["value"]),
330
326
  )
331
327
 
332
- def get_page_version(self, page_id: str) -> int:
328
+ def get_page_version(
329
+ self,
330
+ page_id: str,
331
+ *,
332
+ space_key: Optional[str] = None,
333
+ ) -> int:
334
+ """
335
+ Retrieve a Confluence wiki page version.
336
+
337
+ :param page_id: The Confluence page ID.
338
+ :param space_key: The Confluence space key (unless the default space is to be used).
339
+ :returns: Confluence page version.
340
+ """
341
+
333
342
  path = f"/content/{page_id}"
334
343
  query = {
335
- "spaceKey": self.space_key,
344
+ "spaceKey": space_key or self.space_key,
336
345
  "expand": "version",
337
346
  }
338
347
  data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
339
348
  version = typing.cast(Dict[str, JsonType], data["version"])
340
349
  return typing.cast(int, version["number"])
341
350
 
342
- def update_page(self, page_id: str, new_content: str) -> None:
343
- page = self.get_page(page_id)
351
+ def update_page(
352
+ self,
353
+ page_id: str,
354
+ new_content: str,
355
+ *,
356
+ space_key: Optional[str] = None,
357
+ ) -> None:
358
+ page = self.get_page(page_id, space_key=space_key)
344
359
 
345
360
  try:
346
361
  old_content = sanitize_confluence(page.content)
@@ -355,10 +370,89 @@ class ConfluenceSession:
355
370
  "id": page_id,
356
371
  "type": "page",
357
372
  "title": page.title, # title needs to be unique within a space so the original title is maintained
358
- "space": {"key": self.space_key},
373
+ "space": {"key": space_key or self.space_key},
359
374
  "body": {"storage": {"value": new_content, "representation": "storage"}},
360
375
  "version": {"minorEdit": True, "number": page.version + 1},
361
376
  }
362
377
 
363
378
  LOGGER.info("Updating page: %s", page_id)
364
379
  self._save(path, data)
380
+
381
+ def create_page(
382
+ self,
383
+ parent_page_id: str,
384
+ title: str,
385
+ new_content: str,
386
+ *,
387
+ space_key: Optional[str] = None,
388
+ ) -> ConfluencePage:
389
+ path = "/content/"
390
+ query = {
391
+ "type": "page",
392
+ "title": title,
393
+ "space": {"key": space_key or self.space_key},
394
+ "body": {"storage": {"value": new_content, "representation": "storage"}},
395
+ "ancestors": [{"type": "page", "id": parent_page_id}],
396
+ }
397
+
398
+ LOGGER.info("Creating page: %s", title)
399
+
400
+ url = self._build_url(path)
401
+ response = self.session.post(
402
+ url,
403
+ data=json.dumps(query),
404
+ headers={"Content-Type": "application/json"},
405
+ )
406
+ response.raise_for_status()
407
+
408
+ data = typing.cast(Dict[str, JsonType], response.json())
409
+ version = typing.cast(Dict[str, JsonType], data["version"])
410
+ body = typing.cast(Dict[str, JsonType], data["body"])
411
+ storage = typing.cast(Dict[str, JsonType], body["storage"])
412
+
413
+ return ConfluencePage(
414
+ id=typing.cast(str, data["id"]),
415
+ space_key=space_key or self.space_key,
416
+ title=typing.cast(str, data["title"]),
417
+ version=typing.cast(int, version["number"]),
418
+ content=typing.cast(str, storage["value"]),
419
+ )
420
+
421
+ def page_exists(
422
+ self, title: str, *, space_key: Optional[str] = None
423
+ ) -> Optional[str]:
424
+ path = "/content"
425
+ query = {
426
+ "type": "page",
427
+ "title": title,
428
+ "spaceKey": space_key or self.space_key,
429
+ }
430
+
431
+ LOGGER.info("Checking if page exists with title: %s", title)
432
+
433
+ url = self._build_url(path)
434
+ response = self.session.get(
435
+ url, params=query, headers={"Content-Type": "application/json"}
436
+ )
437
+ response.raise_for_status()
438
+
439
+ data = typing.cast(Dict[str, JsonType], response.json())
440
+ results = typing.cast(List, data["results"])
441
+
442
+ if len(results) == 1:
443
+ page_info = typing.cast(Dict[str, JsonType], results[0])
444
+ return typing.cast(str, page_info["id"])
445
+ else:
446
+ return None
447
+
448
+ def get_or_create_page(
449
+ self, title: str, parent_id: str, *, space_key: Optional[str] = None
450
+ ) -> ConfluencePage:
451
+ page_id = self.page_exists(title)
452
+
453
+ if page_id is not None:
454
+ LOGGER.debug("Retrieving existing page: %d", page_id)
455
+ return self.get_page(page_id)
456
+ else:
457
+ LOGGER.debug("Creating new page with title: %s", title)
458
+ return self.create_page(parent_id, title, "", space_key=space_key)
md2conf/application.py CHANGED
@@ -1,13 +1,15 @@
1
1
  import logging
2
2
  import os.path
3
- from typing import Dict
3
+ from pathlib import Path
4
+ from typing import Dict, Optional
4
5
 
5
6
  from .api import ConfluenceSession
6
7
  from .converter import (
7
8
  ConfluenceDocument,
8
9
  ConfluenceDocumentOptions,
9
10
  ConfluencePageMetadata,
10
- extract_page_id,
11
+ attachment_name,
12
+ extract_qualified_id,
11
13
  )
12
14
 
13
15
  LOGGER = logging.getLogger(__name__)
@@ -25,51 +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
- self._synchronize_page(page_path, dict())
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] = dict()
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":
56
+ # Reconstitute Path object back
57
+ docfile = (Path(root) / file_name).absolute()
58
+
59
+ # Skip non-markdown files
60
+ if docfile.suffix.lower() != ".md":
55
61
  continue
62
+ metadata = self._get_or_create_page(docfile)
56
63
 
57
- # parse file
58
- absolute_path = os.path.join(os.path.abspath(root), file_name)
59
- with open(absolute_path, "r") as f:
60
- document = f.read()
61
-
62
- id, document = extract_page_id(document)
63
- confluence_page = self.api.get_page(id.page_id)
64
- metadata = ConfluencePageMetadata(
65
- domain=self.api.domain,
66
- base_path=self.api.base_path,
67
- page_id=id.page_id,
68
- space_key=id.space_key or self.api.space_key,
69
- title=confluence_page.title or "",
70
- )
71
- LOGGER.debug(f"indexed {absolute_path} with metadata: {metadata}")
72
- page_metadata[absolute_path] = metadata
64
+ LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
65
+ page_metadata[docfile] = metadata
73
66
 
74
67
  LOGGER.info(f"indexed {len(page_metadata)} pages")
75
68
 
@@ -79,10 +72,10 @@ class Application:
79
72
 
80
73
  def _synchronize_page(
81
74
  self,
82
- page_path: str,
83
- page_metadata: Dict[str, ConfluencePageMetadata],
75
+ page_path: Path,
76
+ page_metadata: Dict[Path, ConfluencePageMetadata],
84
77
  ) -> None:
85
- base_path = os.path.dirname(page_path)
78
+ base_path = page_path.parent
86
79
 
87
80
  LOGGER.info(f"Synchronizing page: {page_path}")
88
81
  document = ConfluenceDocument(page_path, self.options, page_metadata)
@@ -93,12 +86,69 @@ class Application:
93
86
  else:
94
87
  self._update_document(document, base_path)
95
88
 
96
- def _update_document(self, document: ConfluenceDocument, base_path: str) -> None:
89
+ def _get_or_create_page(
90
+ self, absolute_path: Path, title: Optional[str] = None
91
+ ) -> ConfluencePageMetadata:
92
+ """
93
+ Creates a new Confluence page if no page is linked in the Markdown document.
94
+ """
95
+
96
+ # parse file
97
+ with open(absolute_path, "r") as f:
98
+ document = f.read()
99
+
100
+ qualified_id, document = extract_qualified_id(document)
101
+ if qualified_id is not None:
102
+ confluence_page = self.api.get_page(
103
+ qualified_id.page_id, space_key=qualified_id.space_key
104
+ )
105
+ else:
106
+ if self.options.root_page_id is None:
107
+ raise ValueError(
108
+ "expected: Confluence page ID to act as parent for Markdown files with no linked Confluence page"
109
+ )
110
+
111
+ # use file name without extension if no title is supplied
112
+ if title is None:
113
+ title = absolute_path.stem
114
+
115
+ confluence_page = self.api.get_or_create_page(
116
+ title, self.options.root_page_id
117
+ )
118
+ self._update_markdown(
119
+ absolute_path,
120
+ document,
121
+ confluence_page.id,
122
+ confluence_page.space_key,
123
+ )
124
+
125
+ return ConfluencePageMetadata(
126
+ domain=self.api.domain,
127
+ base_path=self.api.base_path,
128
+ page_id=confluence_page.id,
129
+ space_key=confluence_page.space_key or self.api.space_key,
130
+ title=confluence_page.title or "",
131
+ )
132
+
133
+ def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
97
134
  for image in document.images:
98
135
  self.api.upload_attachment(
99
- document.id.page_id, os.path.join(base_path, image), image, ""
136
+ document.id.page_id, base_path / image, attachment_name(image), ""
100
137
  )
101
138
 
102
139
  content = document.xhtml()
103
140
  LOGGER.debug(f"generated Confluence Storage Format document:\n{content}")
104
141
  self.api.update_page(document.id.page_id, content)
142
+
143
+ def _update_markdown(
144
+ self,
145
+ path: Path,
146
+ document: str,
147
+ page_id: str,
148
+ space_key: Optional[str],
149
+ ) -> None:
150
+ with open(path, "w") as file:
151
+ file.write(f"<!-- confluence-page-id: {page_id} -->\n")
152
+ if space_key:
153
+ file.write(f"<!-- confluence-space-key: {space_key} -->\n")
154
+ file.write(document)
md2conf/converter.py CHANGED
@@ -207,28 +207,38 @@ 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}"
210
+ @dataclass
211
+ class ConfluenceConverterOptions:
212
+ """
213
+ Options for converting an HTML tree into Confluence storage format.
214
+
215
+ :param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
216
+ plain text; when false, raise an exception.
217
+ """
218
+
219
+ ignore_invalid_url: bool = False
213
220
 
214
221
 
215
222
  class ConfluenceStorageFormatConverter(NodeVisitor):
216
223
  "Transforms a plain HTML tree into the Confluence storage format."
217
224
 
218
- path: str
219
- base_path: str
225
+ options: ConfluenceConverterOptions
226
+ path: pathlib.Path
227
+ base_path: pathlib.Path
220
228
  links: List[str]
221
229
  images: List[str]
222
- page_metadata: Dict[str, ConfluencePageMetadata]
230
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
223
231
 
224
232
  def __init__(
225
233
  self,
226
- path: str,
227
- page_metadata: Dict[str, ConfluencePageMetadata],
234
+ options: ConfluenceConverterOptions,
235
+ path: pathlib.Path,
236
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
228
237
  ) -> None:
229
238
  super().__init__()
239
+ self.options = options
230
240
  self.path = path
231
- self.base_path = os.path.abspath(os.path.dirname(path)) + os.sep
241
+ self.base_path = path.parent
232
242
  self.links = []
233
243
  self.images = []
234
244
  self.page_metadata = page_metadata
@@ -255,15 +265,27 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
255
265
  # convert the relative URL to absolute URL based on the base path value, then look up
256
266
  # the absolute path in the page metadata dictionary to discover the relative path
257
267
  # within Confluence that should be used
258
- absolute_path = os.path.abspath(os.path.join(self.base_path, relative_url.path))
259
- if not absolute_path.startswith(self.base_path):
260
- raise DocumentError(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}"
271
+ if self.options.ignore_invalid_url:
272
+ LOGGER.warning(msg)
273
+ anchor.attrib.pop("href")
274
+ return
275
+ else:
276
+ raise DocumentError(msg)
261
277
 
262
278
  relative_path = os.path.relpath(absolute_path, self.base_path)
263
279
 
264
280
  link_metadata = self.page_metadata.get(absolute_path)
265
281
  if link_metadata is None:
266
- raise DocumentError(f"unable to find matching page for URL: {url}")
282
+ msg = f"unable to find matching page for URL: {url}"
283
+ if self.options.ignore_invalid_url:
284
+ LOGGER.warning(msg)
285
+ anchor.attrib.pop("href")
286
+ return
287
+ else:
288
+ raise DocumentError(msg)
267
289
 
268
290
  LOGGER.debug(
269
291
  f"found link to page {relative_path} with metadata: {link_metadata}"
@@ -287,10 +309,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
287
309
  path: str = image.attrib["src"]
288
310
 
289
311
  # prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
290
- if path and is_relative_url(path) and path.endswith(".svg"):
291
- replacement_path = _change_ext(path, ".png")
292
- if os.path.exists(os.path.join(self.base_path, replacement_path)):
293
- 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"))
294
319
 
295
320
  self.images.append(path)
296
321
  caption = image.attrib["alt"]
@@ -300,7 +325,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
300
325
  ET.QName(namespaces["ac"], "align"): "center",
301
326
  ET.QName(namespaces["ac"], "layout"): "center",
302
327
  },
303
- RI("attachment", {ET.QName(namespaces["ri"], "filename"): path}),
328
+ RI(
329
+ "attachment",
330
+ {ET.QName(namespaces["ri"], "filename"): attachment_name(path)},
331
+ ),
304
332
  AC("caption", HTML.p(caption)),
305
333
  )
306
334
 
@@ -364,6 +392,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
364
392
  if class_name is None:
365
393
  raise DocumentError(f"unsupported admonition label: {class_list}")
366
394
 
395
+ for e in elem:
396
+ self.visit(e)
397
+
367
398
  # <p class="admonition-title">Note</p>
368
399
  if "admonition-title" in elem[0].attrib.get("class", "").split(" "):
369
400
  content = [
@@ -464,12 +495,11 @@ class ConfluenceQualifiedID:
464
495
  space_key: Optional[str] = None
465
496
 
466
497
 
467
- def extract_page_id(string: str) -> Tuple[ConfluenceQualifiedID, str]:
498
+ def extract_qualified_id(string: str) -> Tuple[Optional[ConfluenceQualifiedID], str]:
468
499
  page_id, string = extract_value(r"<!--\s+confluence-page-id:\s*(\d+)\s+-->", string)
500
+
469
501
  if page_id is None:
470
- raise DocumentError(
471
- "Markdown document has no Confluence page ID associated with it"
472
- )
502
+ return None, string
473
503
 
474
504
  # extract Confluence space key
475
505
  space_key, string = extract_value(
@@ -484,10 +514,14 @@ class ConfluenceDocumentOptions:
484
514
  """
485
515
  Options that control the generated page content.
486
516
 
517
+ :param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
518
+ plain text; when false, raise an exception.
487
519
  :param show_generated: Whether to display a prompt "This page has been generated with a tool."
488
520
  """
489
521
 
522
+ ignore_invalid_url: bool = False
490
523
  generated_by: Optional[str] = "This page has been generated with a tool."
524
+ root_page_id: Optional[str] = None
491
525
 
492
526
 
493
527
  class ConfluenceDocument:
@@ -500,24 +534,33 @@ class ConfluenceDocument:
500
534
 
501
535
  def __init__(
502
536
  self,
503
- path: str,
537
+ path: pathlib.Path,
504
538
  options: ConfluenceDocumentOptions,
505
- page_metadata: Dict[str, ConfluencePageMetadata],
539
+ page_metadata: Dict[pathlib.Path, ConfluencePageMetadata],
506
540
  ) -> None:
507
541
  self.options = options
508
- path = os.path.abspath(path)
542
+ path = path.absolute()
509
543
 
510
544
  with open(path, "r") as f:
511
- html = markdown_to_html(f.read())
545
+ text = f.read()
512
546
 
513
547
  # extract Confluence page ID
514
- self.id, html = extract_page_id(html)
548
+ qualified_id, text = extract_qualified_id(text)
549
+ if qualified_id is None:
550
+ raise ValueError("missing Confluence page ID")
551
+ self.id = qualified_id
515
552
 
516
553
  # extract 'generated-by' tag text
517
- generated_by_tag, html = extract_value(
518
- r"<!--\s+generated-by:\s*(.*)\s+-->", html
554
+ generated_by_tag, text = extract_value(
555
+ r"<!--\s+generated-by:\s*(.*)\s+-->", text
519
556
  )
520
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
+
521
564
  # parse Markdown document
522
565
  if self.options.generated_by is not None:
523
566
  generated_by = self.options.generated_by
@@ -534,7 +577,13 @@ class ConfluenceDocument:
534
577
  content = [html]
535
578
  self.root = elements_from_strings(content)
536
579
 
537
- converter = ConfluenceStorageFormatConverter(path, page_metadata)
580
+ converter = ConfluenceStorageFormatConverter(
581
+ ConfluenceConverterOptions(
582
+ ignore_invalid_url=self.options.ignore_invalid_url
583
+ ),
584
+ path,
585
+ page_metadata,
586
+ )
538
587
  converter.visit(self.root)
539
588
  self.links = converter.links
540
589
  self.images = converter.images
@@ -543,6 +592,18 @@ class ConfluenceDocument:
543
592
  return _content_to_string(self.root)
544
593
 
545
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
+
546
607
  def sanitize_confluence(html: str) -> str:
547
608
  "Generates a sanitized version of a Confluence storage format XHTML document with no volatile attributes."
548
609
 
md2conf/processor.py ADDED
@@ -0,0 +1,91 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Dict
5
+
6
+ from .converter import (
7
+ ConfluenceDocument,
8
+ ConfluenceDocumentOptions,
9
+ ConfluencePageMetadata,
10
+ extract_qualified_id,
11
+ )
12
+ from .properties import ConfluenceProperties
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class Processor:
18
+ options: ConfluenceDocumentOptions
19
+ properties: ConfluenceProperties
20
+
21
+ def __init__(
22
+ self, options: ConfluenceDocumentOptions, properties: ConfluenceProperties
23
+ ) -> None:
24
+ self.options = options
25
+ self.properties = properties
26
+
27
+ def process(self, path: Path) -> None:
28
+ "Processes a single Markdown file or a directory of Markdown files."
29
+
30
+ if path.is_dir():
31
+ self.process_directory(path)
32
+ elif path.is_file():
33
+ self.process_page(path, {})
34
+ else:
35
+ raise ValueError(f"expected: valid file or directory path; got: {path}")
36
+
37
+ def process_directory(self, local_dir: Path) -> None:
38
+ "Recursively scans a directory hierarchy for Markdown files."
39
+
40
+ page_metadata: Dict[Path, ConfluencePageMetadata] = {}
41
+ LOGGER.info(f"Synchronizing directory: {local_dir}")
42
+
43
+ # Step 1: build index of all page metadata
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):
47
+ for file_name in files:
48
+ # Reconstitute Path object back
49
+ docfile = (Path(root) / file_name).absolute()
50
+
51
+ # Skip non-markdown files
52
+ if docfile.suffix.lower() != ".md":
53
+ continue
54
+
55
+ metadata = self._get_page(docfile)
56
+ LOGGER.debug(f"indexed {docfile} with metadata: {metadata}")
57
+ page_metadata[docfile] = metadata
58
+
59
+ LOGGER.info(f"indexed {len(page_metadata)} pages")
60
+
61
+ # Step 2: Convert each page
62
+ for page_path in page_metadata.keys():
63
+ self.process_page(page_path, page_metadata)
64
+
65
+ def process_page(
66
+ self, path: Path, page_metadata: Dict[Path, ConfluencePageMetadata]
67
+ ) -> None:
68
+ "Processes a single Markdown file."
69
+
70
+ document = ConfluenceDocument(path, self.options, page_metadata)
71
+ content = document.xhtml()
72
+ with open(path.with_suffix(".csf"), "w") as f:
73
+ f.write(content)
74
+
75
+ def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
76
+ "Extracts metadata from a Markdown file."
77
+
78
+ with open(absolute_path, "r") as f:
79
+ document = f.read()
80
+
81
+ qualified_id, document = extract_qualified_id(document)
82
+ if qualified_id is None:
83
+ raise ValueError("required: page ID for local output")
84
+
85
+ return ConfluencePageMetadata(
86
+ domain=self.properties.domain,
87
+ base_path=self.properties.base_path,
88
+ page_id=qualified_id.page_id,
89
+ space_key=qualified_id.space_key or self.properties.space_key,
90
+ title="",
91
+ )
md2conf/properties.py ADDED
@@ -0,0 +1,52 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+
5
+ class ConfluenceError(RuntimeError):
6
+ pass
7
+
8
+
9
+ class ConfluenceProperties:
10
+ domain: str
11
+ base_path: str
12
+ space_key: str
13
+ user_name: Optional[str]
14
+ api_key: str
15
+
16
+ def __init__(
17
+ self,
18
+ domain: Optional[str] = None,
19
+ base_path: Optional[str] = None,
20
+ user_name: Optional[str] = None,
21
+ api_key: Optional[str] = None,
22
+ space_key: Optional[str] = None,
23
+ ) -> None:
24
+ opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
25
+ opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
26
+ opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
27
+ opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
28
+ opt_space_key = space_key or os.getenv("CONFLUENCE_SPACE_KEY")
29
+
30
+ if not opt_domain:
31
+ raise ConfluenceError("Confluence domain not specified")
32
+ if not opt_base_path:
33
+ opt_base_path = "/wiki/"
34
+ if not opt_api_key:
35
+ raise ConfluenceError("Confluence API key not specified")
36
+ if not opt_space_key:
37
+ raise ConfluenceError("Confluence space key not specified")
38
+
39
+ if opt_domain.startswith(("http://", "https://")) or opt_domain.endswith("/"):
40
+ raise ConfluenceError(
41
+ "Confluence domain looks like a URL; only host name required"
42
+ )
43
+ if not opt_base_path.startswith("/") or not opt_base_path.endswith("/"):
44
+ raise ConfluenceError("Confluence base path must start and end with a '/'")
45
+
46
+ self.domain = opt_domain
47
+ self.base_path = opt_base_path
48
+ self.user_name = opt_user_name
49
+ self.api_key = opt_api_key
50
+ self.space_key = opt_space_key
51
+ self.space_key = opt_space_key
52
+ self.space_key = opt_space_key
@@ -1,13 +0,0 @@
1
- md2conf/__init__.py,sha256=87hc-oGK6Iy7HpMb4r0ujxMFZTbmrMValBsbRy1xySA,403
2
- md2conf/__main__.py,sha256=vqSQ1jYCDXcPw7xG6MxbY5qDgwJxFBYPgp25CqniiNE,2639
3
- md2conf/api.py,sha256=qomqdn8sztLe5jBaiqbA-YqSt7W3Kp_2K4-amJhMYK4,11946
4
- md2conf/application.py,sha256=DOaPHuvWQNHmLuTWnTeLvWvgn4vvy3NN4eM-UGND2Qs,3670
5
- md2conf/converter.py,sha256=WDm_MlzGf3suTvSbbngUjODigWrlP8A6PpqqFRT0NZY,16410
6
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- markdown_to_confluence-0.1.10.dist-info/LICENSE,sha256=W_F6JttWaUq8-m1T7FrjMU0PYCTHYC-H4DUaPNZ7YGc,1077
9
- markdown_to_confluence-0.1.10.dist-info/METADATA,sha256=z4wxlc22eWiujPC-09-p9dE-OnrzEH4FAXKgIIC6MIo,7468
10
- markdown_to_confluence-0.1.10.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
11
- markdown_to_confluence-0.1.10.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
12
- markdown_to_confluence-0.1.10.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
13
- markdown_to_confluence-0.1.10.dist-info/RECORD,,