markdown-to-confluence 0.3.1__py3-none-any.whl → 0.3.3__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
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -30,6 +30,7 @@ Requires-Dist: pyyaml>=6.0
30
30
  Requires-Dist: types-PyYAML>=6.0
31
31
  Requires-Dist: requests>=2.32
32
32
  Requires-Dist: types-requests>=2.32
33
+ Dynamic: license-file
33
34
 
34
35
  # Publish Markdown files to Confluence wiki
35
36
 
@@ -166,7 +167,7 @@ The concepts above are illustrated in the following sections.
166
167
 
167
168
  #### File-system directory hierarchy
168
169
 
169
- The title of each Markdown file (either the text of the first heading (`#`), or the title specified in front-matter) is shown next to the file name.
170
+ The title of each Markdown file (either the text of the topmost unique heading (`#`), or the title specified in front-matter) is shown next to the file name.
170
171
 
171
172
  ```
172
173
  .
@@ -197,12 +198,30 @@ root
197
198
  └── Mean vs. median
198
199
  ```
199
200
 
201
+ ### Publishing images
202
+
203
+ Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
204
+
205
+ Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
206
+
207
+ External images referenced with an absolute URL retain the original URL.
208
+
200
209
  ### Ignoring files
201
210
 
202
211
  Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
203
212
 
204
213
  Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
205
214
 
215
+ ### Page title
216
+
217
+ *md2conf* makes a best-effort attempt at setting the Confluence wiki page title when it publishes a Markdown document the first time. The following are probed in this order:
218
+
219
+ 1. The `title` attribute set in the [front-matter](https://daily-dev-tips.com/posts/what-exactly-is-frontmatter/). Front-matter is a block delimited by `---` at the beginning of a Markdown document. Currently, only YAML syntax is supported.
220
+ 2. The text of the topmost unique Markdown heading (`#`). For example, if a document has a single first-level heading (e.g. `# My document`), its text is used. However, if there are multiple first-level headings, this step is skipped.
221
+ 3. The file name (without the extension `.md`).
222
+
223
+ If a matching Confluence page already exists for a Markdown file, the page title in Confluence is left unchanged.
224
+
206
225
  ### Running the tool
207
226
 
208
227
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
@@ -215,10 +234,8 @@ Use the `--help` switch to get a full list of supported command-line options:
215
234
 
216
235
  ```console
217
236
  $ python3 -m md2conf --help
218
- usage: md2conf [-h] [--version] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
219
- [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY] [--no-generated-by]
220
- [--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors]
221
- [--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
237
+ usage: md2conf [-h] [--version] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--keep-hierarchy] [--generated-by GENERATED_BY] [--no-generated-by]
238
+ [--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors] [--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
222
239
  mdpath
223
240
 
224
241
  positional arguments:
@@ -239,6 +256,7 @@ options:
239
256
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
240
257
  Use this option to set the log verbosity.
241
258
  -r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
259
+ --keep-hierarchy Maintain source directory structure when exporting to Confluence.
242
260
  --generated-by GENERATED_BY
243
261
  Add prompt to pages (default: 'This page has been generated with a tool.').
244
262
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
@@ -0,0 +1,20 @@
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.2)
2
+ Generator: setuptools (80.3.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
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.1"
8
+ __version__ = "0.3.3"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -22,18 +22,22 @@ import requests
22
22
  from . import __version__
23
23
  from .api import ConfluenceAPI
24
24
  from .application import Application
25
- from .converter import ConfluenceDocumentOptions
25
+ from .converter import ConfluenceDocumentOptions, ConfluenceSiteMetadata
26
26
  from .processor import Processor
27
- from .properties import ConfluenceProperties
27
+ from .properties import (
28
+ ArgumentError,
29
+ ConfluenceConnectionProperties,
30
+ ConfluenceSiteProperties,
31
+ )
28
32
 
29
33
 
30
34
  class Arguments(argparse.Namespace):
31
35
  mdpath: Path
32
- domain: str
33
- path: str
34
- username: str
35
- apikey: str
36
- space: str
36
+ domain: Optional[str]
37
+ path: Optional[str]
38
+ username: Optional[str]
39
+ apikey: Optional[str]
40
+ space: Optional[str]
37
41
  loglevel: str
38
42
  ignore_invalid_url: bool
39
43
  heading_anchors: bool
@@ -201,12 +205,33 @@ def main() -> None:
201
205
  diagram_output_format=args.diagram_output_format,
202
206
  webui_links=args.webui_links,
203
207
  )
204
- properties = ConfluenceProperties(
205
- args.domain, args.path, args.username, args.apikey, args.space, args.headers
206
- )
207
208
  if args.local:
208
- Processor(options, properties).process(args.mdpath)
209
+ try:
210
+ site_properties = ConfluenceSiteProperties(
211
+ domain=args.domain,
212
+ base_path=args.path,
213
+ space_key=args.space,
214
+ )
215
+ except ArgumentError as e:
216
+ parser.error(str(e))
217
+ site_metadata = ConfluenceSiteMetadata(
218
+ domain=site_properties.domain,
219
+ base_path=site_properties.base_path,
220
+ space_key=site_properties.space_key,
221
+ )
222
+ Processor(options, site_metadata).process(args.mdpath)
209
223
  else:
224
+ try:
225
+ properties = ConfluenceConnectionProperties(
226
+ args.domain,
227
+ args.path,
228
+ args.username,
229
+ args.apikey,
230
+ args.space,
231
+ args.headers,
232
+ )
233
+ except ArgumentError as e:
234
+ parser.error(str(e))
210
235
  try:
211
236
  with ConfluenceAPI(properties) as api:
212
237
  Application(
md2conf/api.py CHANGED
@@ -21,7 +21,12 @@ from urllib.parse import urlencode, urlparse, urlunparse
21
21
  import requests
22
22
 
23
23
  from .converter import ParseError, sanitize_confluence
24
- from .properties import ConfluenceError, ConfluenceProperties
24
+ from .properties import (
25
+ ArgumentError,
26
+ ConfluenceConnectionProperties,
27
+ ConfluenceError,
28
+ PageError,
29
+ )
25
30
 
26
31
  # a JSON type with possible `null` values
27
32
  JsonType = Union[
@@ -40,6 +45,18 @@ class ConfluenceVersion(enum.Enum):
40
45
  VERSION_2 = "api/v2"
41
46
 
42
47
 
48
+ class ConfluencePageParentContentType(enum.Enum):
49
+ """
50
+ Content types that can be a parent to a Confluence page
51
+ """
52
+
53
+ PAGE = "page"
54
+ WHITEBOARD = "whiteboard"
55
+ DATABASE = "database"
56
+ EMBED = "embed"
57
+ FOLDER = "folder"
58
+
59
+
43
60
  def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
44
61
  "Builds a URL with scheme, host, port, path and query string parameters."
45
62
 
@@ -71,17 +88,21 @@ class ConfluenceAttachment:
71
88
  class ConfluencePage:
72
89
  id: str
73
90
  space_id: str
91
+ parent_id: str
92
+ parent_type: Optional[ConfluencePageParentContentType]
74
93
  title: str
75
94
  version: int
76
95
  content: str
77
96
 
78
97
 
79
98
  class ConfluenceAPI:
80
- properties: ConfluenceProperties
99
+ properties: ConfluenceConnectionProperties
81
100
  session: Optional["ConfluenceSession"] = None
82
101
 
83
- def __init__(self, properties: Optional[ConfluenceProperties] = None) -> None:
84
- self.properties = properties or ConfluenceProperties()
102
+ def __init__(
103
+ self, properties: Optional[ConfluenceConnectionProperties] = None
104
+ ) -> None:
105
+ self.properties = properties or ConfluenceConnectionProperties()
85
106
 
86
107
  def __enter__(self) -> "ConfluenceSession":
87
108
  session = requests.Session()
@@ -128,7 +149,7 @@ class ConfluenceSession:
128
149
  session: requests.Session,
129
150
  domain: str,
130
151
  base_path: str,
131
- space_key: Optional[str],
152
+ space_key: Optional[str] = None,
132
153
  ) -> None:
133
154
  self.session = session
134
155
  self.domain = domain
@@ -170,11 +191,9 @@ class ConfluenceSession:
170
191
 
171
192
  url = self._build_url(version, path, query)
172
193
  response = self.session.get(url)
173
- response.raise_for_status()
174
- if len(response.text) > 240:
175
- LOGGER.debug("Received HTTP payload (truncated):\n%.240s...", response.text)
176
- else:
194
+ if response.text:
177
195
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
196
+ response.raise_for_status()
178
197
  return response.json()
179
198
 
180
199
  def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
@@ -184,6 +203,8 @@ class ConfluenceSession:
184
203
  data=json.dumps(data),
185
204
  headers={"Content-Type": "application/json"},
186
205
  )
206
+ if response.text:
207
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
187
208
  response.raise_for_status()
188
209
 
189
210
  def space_id_to_key(self, id: str) -> str:
@@ -194,7 +215,7 @@ class ConfluenceSession:
194
215
  payload = self._invoke(
195
216
  ConfluenceVersion.VERSION_2,
196
217
  "/spaces",
197
- {"ids": id, "type": "global", "status": "current"},
218
+ {"ids": id, "status": "current"},
198
219
  )
199
220
  payload = typing.cast(dict[str, JsonType], payload)
200
221
  results = typing.cast(list[JsonType], payload["results"])
@@ -216,7 +237,7 @@ class ConfluenceSession:
216
237
  payload = self._invoke(
217
238
  ConfluenceVersion.VERSION_2,
218
239
  "/spaces",
219
- {"keys": key, "type": "global", "status": "current"},
240
+ {"keys": key, "status": "current"},
220
241
  )
221
242
  payload = typing.cast(dict[str, JsonType], payload)
222
243
  results = typing.cast(list[JsonType], payload["results"])
@@ -261,12 +282,11 @@ class ConfluenceSession:
261
282
  comment: Optional[str] = None,
262
283
  force: bool = False,
263
284
  ) -> None:
264
-
265
285
  if attachment_path is None and raw_data is None:
266
- raise ConfluenceError("required: `attachment_path` or `raw_data`")
286
+ raise ArgumentError("required: `attachment_path` or `raw_data`")
267
287
 
268
288
  if attachment_path is not None and raw_data is not None:
269
- raise ConfluenceError("expected: either `attachment_path` or `raw_data`")
289
+ raise ArgumentError("expected: either `attachment_path` or `raw_data`")
270
290
 
271
291
  if content_type is None:
272
292
  if attachment_path is not None:
@@ -276,7 +296,7 @@ class ConfluenceSession:
276
296
  content_type, _ = mimetypes.guess_type(name, strict=True)
277
297
 
278
298
  if attachment_path is not None and not attachment_path.is_file():
279
- raise ConfluenceError(f"file not found: {attachment_path}")
299
+ raise PageError(f"file not found: {attachment_path}")
280
300
 
281
301
  try:
282
302
  attachment = self.get_attachment_by_name(page_id, attachment_name)
@@ -422,6 +442,12 @@ class ConfluenceSession:
422
442
  return ConfluencePage(
423
443
  id=page_id,
424
444
  space_id=typing.cast(str, data["spaceId"]),
445
+ parent_id=typing.cast(str, data["parentId"]),
446
+ parent_type=(
447
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
448
+ if data["parentType"] is not None
449
+ else None
450
+ ),
425
451
  title=typing.cast(str, data["title"]),
426
452
  version=typing.cast(int, version["number"]),
427
453
  content=typing.cast(str, storage["value"]),
@@ -493,9 +519,7 @@ class ConfluenceSession:
493
519
 
494
520
  coalesced_space_key = space_key or self.space_key
495
521
  if coalesced_space_key is None:
496
- raise ConfluenceError(
497
- "Confluence space key required for creating a new page"
498
- )
522
+ raise ArgumentError("Confluence space key required for creating a new page")
499
523
 
500
524
  path = "/pages/"
501
525
  query = {
@@ -524,6 +548,12 @@ class ConfluenceSession:
524
548
  return ConfluencePage(
525
549
  id=typing.cast(str, data["id"]),
526
550
  space_id=typing.cast(str, data["spaceId"]),
551
+ parent_id=typing.cast(str, data["parentId"]),
552
+ parent_type=(
553
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
554
+ if data["parentType"] is not None
555
+ else None
556
+ ),
527
557
  title=typing.cast(str, data["title"]),
528
558
  version=typing.cast(int, version["number"]),
529
559
  content=typing.cast(str, storage["value"]),
md2conf/application.py CHANGED
@@ -6,8 +6,9 @@ Copyright 2022-2025, Levente Hunyadi
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
+ import hashlib
9
10
  import logging
10
- import os.path
11
+ import os
11
12
  from pathlib import Path
12
13
  from typing import Optional
13
14
 
@@ -17,12 +18,14 @@ from .converter import (
17
18
  ConfluenceDocumentOptions,
18
19
  ConfluencePageMetadata,
19
20
  ConfluenceQualifiedID,
21
+ ConfluenceSiteMetadata,
20
22
  attachment_name,
21
23
  extract_frontmatter_title,
22
24
  extract_qualified_id,
23
25
  read_qualified_id,
24
26
  )
25
27
  from .matcher import Matcher, MatcherOptions
28
+ from .properties import ArgumentError, PageError
26
29
 
27
30
  LOGGER = logging.getLogger(__name__)
28
31
 
@@ -48,7 +51,7 @@ class Application:
48
51
  elif path.is_file():
49
52
  self.synchronize_page(path)
50
53
  else:
51
- raise ValueError(f"expected: valid file or directory path; got: {path}")
54
+ raise ArgumentError(f"expected: valid file or directory path; got: {path}")
52
55
 
53
56
  def synchronize_page(
54
57
  self, page_path: Path, root_dir: Optional[Path] = None
@@ -83,7 +86,7 @@ class Application:
83
86
  if self.options.root_page_id
84
87
  else None
85
88
  )
86
- self._index_directory(local_dir, root_id, page_metadata)
89
+ self._index_directory(local_dir, root_dir, root_id, page_metadata)
87
90
  LOGGER.info("Indexed %d page(s)", len(page_metadata))
88
91
 
89
92
  # Step 2: convert each page
@@ -99,12 +102,21 @@ class Application:
99
102
  base_path = page_path.parent
100
103
 
101
104
  LOGGER.info("Synchronizing page: %s", page_path)
102
- document = ConfluenceDocument(page_path, self.options, root_dir, page_metadata)
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
+ )
103
114
  self._update_document(document, base_path)
104
115
 
105
116
  def _index_directory(
106
117
  self,
107
118
  local_dir: Path,
119
+ root_dir: Path,
108
120
  root_id: Optional[ConfluenceQualifiedID],
109
121
  page_metadata: dict[Path, ConfluencePageMetadata],
110
122
  ) -> None:
@@ -144,7 +156,7 @@ class Application:
144
156
  if parent_doc is not None:
145
157
  files.remove(parent_doc)
146
158
 
147
- metadata = self._get_or_create_page(parent_doc, root_id)
159
+ metadata = self._get_or_create_page(parent_doc, root_dir, root_id)
148
160
  LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
149
161
  page_metadata[parent_doc] = metadata
150
162
 
@@ -153,16 +165,17 @@ class Application:
153
165
  parent_id = root_id
154
166
 
155
167
  for doc in files:
156
- metadata = self._get_or_create_page(doc, parent_id)
168
+ metadata = self._get_or_create_page(doc, root_dir, parent_id)
157
169
  LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
158
170
  page_metadata[doc] = metadata
159
171
 
160
172
  for directory in directories:
161
- self._index_directory(directory, parent_id, page_metadata)
173
+ self._index_directory(directory, root_dir, parent_id, page_metadata)
162
174
 
163
175
  def _get_or_create_page(
164
176
  self,
165
177
  absolute_path: Path,
178
+ root_dir: Path,
166
179
  parent_id: Optional[ConfluenceQualifiedID],
167
180
  *,
168
181
  title: Optional[str] = None,
@@ -176,19 +189,28 @@ class Application:
176
189
  document = f.read()
177
190
 
178
191
  qualified_id, document = extract_qualified_id(document)
179
- frontmatter_title, _ = extract_frontmatter_title(document)
180
192
 
181
193
  if qualified_id is not None:
182
194
  confluence_page = self.api.get_page(qualified_id.page_id)
183
195
  else:
184
196
  if parent_id is None:
185
- raise ValueError(
197
+ raise PageError(
186
198
  f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
187
199
  )
188
200
 
189
- # assign title from frontmatter if present
201
+ # assign title from front-matter if present
202
+ if title is None:
203
+ title, _ = extract_frontmatter_title(document)
204
+
205
+ # use file name (without extension) and path hash if no title is supplied
206
+ if title is None:
207
+ relative_path = absolute_path.relative_to(root_dir)
208
+ hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
209
+ digest = "".join(f"{c:x}" for c in hash.digest())
210
+ title = f"{absolute_path.stem} [{digest}]"
211
+
190
212
  confluence_page = self._create_page(
191
- absolute_path, document, title or frontmatter_title, parent_id
213
+ absolute_path, document, title, parent_id
192
214
  )
193
215
 
194
216
  space_key = (
@@ -198,8 +220,6 @@ class Application:
198
220
  )
199
221
 
200
222
  return ConfluencePageMetadata(
201
- domain=self.api.domain,
202
- base_path=self.api.base_path,
203
223
  page_id=confluence_page.id,
204
224
  space_key=space_key,
205
225
  title=confluence_page.title or "",
@@ -209,15 +229,11 @@ class Application:
209
229
  self,
210
230
  absolute_path: Path,
211
231
  document: str,
212
- title: Optional[str],
232
+ title: str,
213
233
  parent_id: ConfluenceQualifiedID,
214
234
  ) -> ConfluencePage:
215
235
  "Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet."
216
236
 
217
- # use file name without extension if no title is supplied
218
- if title is None:
219
- title = absolute_path.stem
220
-
221
237
  confluence_page = self.api.get_or_create_page(
222
238
  title, parent_id.page_id, space_key=parent_id.space_key
223
239
  )
md2conf/converter.py CHANGED
@@ -25,7 +25,8 @@ import markdown
25
25
  import yaml
26
26
  from lxml.builder import ElementMaker
27
27
 
28
- from . import mermaid
28
+ from .mermaid import render_diagram
29
+ from .properties import PageError
29
30
 
30
31
  namespaces = {
31
32
  "ac": "http://atlassian.com/content",
@@ -91,9 +92,11 @@ def markdown_to_html(content: str) -> str:
91
92
  extensions=[
92
93
  "admonition",
93
94
  "markdown.extensions.tables",
94
- "markdown.extensions.fenced_code",
95
+ # "markdown.extensions.fenced_code",
95
96
  "pymdownx.emoji",
97
+ "pymdownx.highlight", # required by `pymdownx.superfences`
96
98
  "pymdownx.magiclink",
99
+ "pymdownx.superfences",
97
100
  "pymdownx.tilde",
98
101
  "sane_lists",
99
102
  "md_in_html",
@@ -101,7 +104,10 @@ def markdown_to_html(content: str) -> str:
101
104
  extension_configs={
102
105
  "pymdownx.emoji": {
103
106
  "emoji_generator": emoji_generator,
104
- }
107
+ },
108
+ "pymdownx.highlight": {
109
+ "use_pygments": False,
110
+ },
105
111
  },
106
112
  )
107
113
 
@@ -235,9 +241,14 @@ _languages = [
235
241
 
236
242
 
237
243
  @dataclass
238
- class ConfluencePageMetadata:
244
+ class ConfluenceSiteMetadata:
239
245
  domain: str
240
246
  base_path: str
247
+ space_key: Optional[str]
248
+
249
+
250
+ @dataclass
251
+ class ConfluencePageMetadata:
241
252
  page_id: str
242
253
  space_key: Optional[str]
243
254
  title: str
@@ -271,6 +282,53 @@ def title_to_identifier(title: str) -> str:
271
282
  return s
272
283
 
273
284
 
285
+ def element_to_text(node: ET._Element) -> str:
286
+ "Returns all text contained in an element as a concatenated string."
287
+
288
+ return "".join(node.itertext()).strip()
289
+
290
+
291
+ @dataclass
292
+ class TableOfContentsEntry:
293
+ level: int
294
+ text: str
295
+
296
+
297
+ class TableOfContents:
298
+ "Builds a table of contents from Markdown headings."
299
+
300
+ headings: list[TableOfContentsEntry]
301
+
302
+ def __init__(self) -> None:
303
+ self.headings = []
304
+
305
+ def add(self, level: int, text: str) -> None:
306
+ """
307
+ Adds a heading to the table of contents.
308
+
309
+ :param level: Markdown heading level (e.g. `1` for first-level heading).
310
+ :param text: Markdown heading text.
311
+ """
312
+
313
+ self.headings.append(TableOfContentsEntry(level, text))
314
+
315
+ def get_title(self) -> Optional[str]:
316
+ """
317
+ Returns a proposed document title (if unique).
318
+
319
+ :returns: Title text, or `None` if no unique title can be inferred.
320
+ """
321
+
322
+ for level in range(1, 7):
323
+ try:
324
+ (title,) = (item.text for item in self.headings if item.level == level)
325
+ return title
326
+ except ValueError:
327
+ pass
328
+
329
+ return None
330
+
331
+
274
332
  @dataclass
275
333
  class ConfluenceConverterOptions:
276
334
  """
@@ -299,9 +357,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
299
357
  path: Path
300
358
  base_dir: Path
301
359
  root_dir: Path
360
+ toc: TableOfContents
302
361
  links: list[str]
303
362
  images: list[Path]
304
363
  embedded_images: dict[str, bytes]
364
+ site_metadata: ConfluenceSiteMetadata
305
365
  page_metadata: dict[Path, ConfluencePageMetadata]
306
366
 
307
367
  def __init__(
@@ -309,6 +369,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
309
369
  options: ConfluenceConverterOptions,
310
370
  path: Path,
311
371
  root_dir: Path,
372
+ site_metadata: ConfluenceSiteMetadata,
312
373
  page_metadata: dict[Path, ConfluencePageMetadata],
313
374
  ) -> None:
314
375
  super().__init__()
@@ -316,14 +377,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
316
377
  self.path = path
317
378
  self.base_dir = path.parent
318
379
  self.root_dir = root_dir
380
+ self.toc = TableOfContents()
319
381
  self.links = []
320
382
  self.images = []
321
383
  self.embedded_images = {}
384
+ self.site_metadata = site_metadata
322
385
  self.page_metadata = page_metadata
323
386
 
324
387
  def _transform_heading(self, heading: ET._Element) -> None:
325
- title = "".join(heading.itertext()).strip()
326
-
327
388
  for e in heading:
328
389
  self.visit(e)
329
390
 
@@ -336,7 +397,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
336
397
  AC(
337
398
  "parameter",
338
399
  {ET.QName(namespaces["ac"], "name"): ""},
339
- title_to_identifier(title),
400
+ title_to_identifier(element_to_text(heading)),
340
401
  ),
341
402
  )
342
403
 
@@ -409,13 +470,20 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
409
470
  self.links.append(url)
410
471
 
411
472
  if self.options.webui_links:
412
- page_url = f"{link_metadata.base_path}pages/viewpage.action?pageId={link_metadata.page_id}"
473
+ page_url = f"{self.site_metadata.base_path}pages/viewpage.action?pageId={link_metadata.page_id}"
413
474
  else:
414
- page_url = f"{link_metadata.base_path}spaces/{link_metadata.space_key}/pages/{link_metadata.page_id}/{link_metadata.title}"
475
+ space_key = link_metadata.space_key or self.site_metadata.space_key
476
+
477
+ if space_key is None:
478
+ raise DocumentError(
479
+ "Confluence space key required for building full web URLs"
480
+ )
481
+
482
+ page_url = f"{self.site_metadata.base_path}spaces/{space_key}/pages/{link_metadata.page_id}/{link_metadata.title}"
415
483
 
416
484
  components = ParseResult(
417
485
  scheme="https",
418
- netloc=link_metadata.domain,
486
+ netloc=self.site_metadata.domain,
419
487
  path=page_url,
420
488
  params="",
421
489
  query="",
@@ -527,11 +595,6 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
527
595
  {ET.QName(namespaces["ac"], "name"): "language"},
528
596
  language,
529
597
  ),
530
- AC(
531
- "parameter",
532
- {ET.QName(namespaces["ac"], "name"): "linenumbers"},
533
- "true",
534
- ),
535
598
  AC("plain-text-body", ET.CDATA(content)),
536
599
  )
537
600
 
@@ -539,7 +602,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
539
602
  "Transforms a Mermaid diagram code block."
540
603
 
541
604
  if self.options.render_mermaid:
542
- image_data = mermaid.render(content, self.options.diagram_output_format)
605
+ image_data = render_diagram(content, self.options.diagram_output_format)
543
606
  image_hash = hashlib.md5(image_data).hexdigest()
544
607
  image_filename = attachment_name(
545
608
  f"embedded_{image_hash}.{self.options.diagram_output_format}"
@@ -799,10 +862,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
799
862
  if not isinstance(child.tag, str):
800
863
  return None
801
864
 
802
- if self.options.heading_anchors:
803
- # <h1>...</h1>
804
- # <h2>...</h2> ...
805
- if re.match(r"^h[1-6]$", child.tag, flags=re.IGNORECASE) is not None:
865
+ # <h1>...</h1>
866
+ # <h2>...</h2> ...
867
+ m = re.match(r"^h([1-6])$", child.tag, flags=re.IGNORECASE)
868
+ if m is not None:
869
+ level = int(m.group(1))
870
+ title = element_to_text(child)
871
+ self.toc.add(level, title)
872
+
873
+ if self.options.heading_anchors:
806
874
  self._transform_heading(child)
807
875
  return None
808
876
 
@@ -891,7 +959,7 @@ class ConfluenceStorageFormatCleaner(NodeVisitor):
891
959
 
892
960
 
893
961
  class DocumentError(RuntimeError):
894
- pass
962
+ "Raised when a converted Markdown document has an unexpected element or attribute."
895
963
 
896
964
 
897
965
  def extract_value(pattern: str, text: str) -> tuple[Optional[str], str]:
@@ -996,14 +1064,15 @@ class ConfluenceDocument:
996
1064
  options: ConfluenceDocumentOptions
997
1065
  root: ET._Element
998
1066
 
999
- def __init__(
1000
- self,
1067
+ @classmethod
1068
+ def create(
1069
+ cls,
1001
1070
  path: Path,
1002
1071
  options: ConfluenceDocumentOptions,
1003
1072
  root_dir: Path,
1073
+ site_metadata: ConfluenceSiteMetadata,
1004
1074
  page_metadata: dict[Path, ConfluencePageMetadata],
1005
- ) -> None:
1006
- self.options = options
1075
+ ) -> "ConfluenceDocument":
1007
1076
  path = path.resolve(True)
1008
1077
 
1009
1078
  with open(path, "r", encoding="utf-8") as f:
@@ -1019,7 +1088,23 @@ class ConfluenceDocument:
1019
1088
  metadata.page_id, metadata.space_key
1020
1089
  )
1021
1090
  if qualified_id is None:
1022
- raise ValueError("missing Confluence page ID")
1091
+ raise PageError("missing Confluence page ID")
1092
+
1093
+ return ConfluenceDocument(
1094
+ path, text, qualified_id, options, root_dir, site_metadata, page_metadata
1095
+ )
1096
+
1097
+ def __init__(
1098
+ self,
1099
+ path: Path,
1100
+ text: str,
1101
+ qualified_id: ConfluenceQualifiedID,
1102
+ options: ConfluenceDocumentOptions,
1103
+ root_dir: Path,
1104
+ site_metadata: ConfluenceSiteMetadata,
1105
+ page_metadata: dict[Path, ConfluencePageMetadata],
1106
+ ) -> None:
1107
+ self.options = options
1023
1108
  self.id = qualified_id
1024
1109
 
1025
1110
  # extract 'generated-by' tag text
@@ -1059,6 +1144,7 @@ class ConfluenceDocument:
1059
1144
  ),
1060
1145
  path,
1061
1146
  root_dir,
1147
+ site_metadata,
1062
1148
  page_metadata,
1063
1149
  )
1064
1150
  converter.visit(self.root)
@@ -1066,6 +1152,9 @@ class ConfluenceDocument:
1066
1152
  self.images = converter.images
1067
1153
  self.embedded_images = converter.embedded_images
1068
1154
 
1155
+ if self.title is None:
1156
+ self.title = converter.toc.get_title()
1157
+
1069
1158
  def xhtml(self) -> str:
1070
1159
  return elements_to_string(self.root)
1071
1160
 
md2conf/emoji.py CHANGED
@@ -10,6 +10,8 @@ import pathlib
10
10
 
11
11
  import pymdownx.emoji1_db as emoji_db
12
12
 
13
+ EMOJI_PAGE_ID = "86918529216"
14
+
13
15
 
14
16
  def generate_source(path: pathlib.Path) -> None:
15
17
  "Generates a source Markdown document for testing emojis."
@@ -17,7 +19,7 @@ def generate_source(path: pathlib.Path) -> None:
17
19
  emojis = emoji_db.emoji
18
20
 
19
21
  with open(path, "w") as f:
20
- print("<!-- confluence-page-id: 86918529216 -->", file=f)
22
+ print(f"<!-- confluence-page-id: {EMOJI_PAGE_ID} -->", file=f)
21
23
  print("<!-- This file has been generated by a script. -->", file=f)
22
24
  print(file=f)
23
25
  print("## Emoji", file=f)
md2conf/mermaid.py CHANGED
@@ -47,7 +47,7 @@ def has_mmdc() -> bool:
47
47
  return shutil.which(executable) is not None
48
48
 
49
49
 
50
- def render(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
50
+ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
51
51
  "Generates a PNG or SVG image from a Mermaid diagram source."
52
52
 
53
53
  filename = f"tmp_mermaid.{output_format}"
md2conf/processor.py CHANGED
@@ -17,23 +17,24 @@ from .converter import (
17
17
  ConfluenceDocumentOptions,
18
18
  ConfluencePageMetadata,
19
19
  ConfluenceQualifiedID,
20
+ ConfluenceSiteMetadata,
20
21
  extract_qualified_id,
21
22
  )
22
23
  from .matcher import Matcher, MatcherOptions
23
- from .properties import ConfluenceProperties
24
+ from .properties import ArgumentError
24
25
 
25
26
  LOGGER = logging.getLogger(__name__)
26
27
 
27
28
 
28
29
  class Processor:
29
30
  options: ConfluenceDocumentOptions
30
- properties: ConfluenceProperties
31
+ site_metadata: ConfluenceSiteMetadata
31
32
 
32
33
  def __init__(
33
- self, options: ConfluenceDocumentOptions, properties: ConfluenceProperties
34
+ self, options: ConfluenceDocumentOptions, site_metadata: ConfluenceSiteMetadata
34
35
  ) -> None:
35
36
  self.options = options
36
- self.properties = properties
37
+ self.site_metadata = site_metadata
37
38
 
38
39
  def process(self, path: Path) -> None:
39
40
  "Processes a single Markdown file or a directory of Markdown files."
@@ -44,7 +45,7 @@ class Processor:
44
45
  elif path.is_file():
45
46
  self.process_page(path)
46
47
  else:
47
- raise ValueError(f"expected: valid file or directory path; got: {path}")
48
+ raise ArgumentError(f"expected: valid file or directory path; got: {path}")
48
49
 
49
50
  def process_directory(
50
51
  self, local_dir: Path, root_dir: Optional[Path] = None
@@ -87,7 +88,9 @@ class Processor:
87
88
  ) -> None:
88
89
  "Processes a single Markdown file."
89
90
 
90
- document = ConfluenceDocument(path, self.options, root_dir, page_metadata)
91
+ document = ConfluenceDocument.create(
92
+ path, self.options, root_dir, self.site_metadata, page_metadata
93
+ )
91
94
  content = document.xhtml()
92
95
  with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
93
96
  f.write(content)
@@ -136,12 +139,10 @@ class Processor:
136
139
  LOGGER.info("Identifier %s assigned to page: %s", digest, absolute_path)
137
140
  qualified_id = ConfluenceQualifiedID(digest)
138
141
  else:
139
- raise ValueError("required: page ID for local output")
142
+ raise ArgumentError("required: page ID for local output")
140
143
 
141
144
  return ConfluencePageMetadata(
142
- domain=self.properties.domain,
143
- base_path=self.properties.base_path,
144
145
  page_id=qualified_id.page_id,
145
- space_key=qualified_id.space_key or self.properties.space_key,
146
+ space_key=qualified_id.space_key,
146
147
  title="",
147
148
  )
md2conf/properties.py CHANGED
@@ -10,50 +10,74 @@ import os
10
10
  from typing import Optional
11
11
 
12
12
 
13
+ class ArgumentError(ValueError):
14
+ "Raised when wrong arguments are passed to a function call."
15
+
16
+
17
+ class PageError(ValueError):
18
+ "Raised in case there is an issue with a Confluence page."
19
+
20
+
13
21
  class ConfluenceError(RuntimeError):
14
- pass
22
+ "Raised when a Confluence API call fails."
15
23
 
16
24
 
17
- class ConfluenceProperties:
25
+ class ConfluenceSiteProperties:
18
26
  domain: str
19
27
  base_path: str
20
28
  space_key: Optional[str]
21
- user_name: Optional[str]
22
- api_key: str
23
- headers: Optional[dict[str, str]]
24
29
 
25
30
  def __init__(
26
31
  self,
27
32
  domain: Optional[str] = None,
28
33
  base_path: Optional[str] = None,
29
- user_name: Optional[str] = None,
30
- api_key: Optional[str] = None,
31
34
  space_key: Optional[str] = None,
32
- headers: Optional[dict[str, str]] = None,
33
35
  ) -> None:
34
36
  opt_domain = domain or os.getenv("CONFLUENCE_DOMAIN")
35
37
  opt_base_path = base_path or os.getenv("CONFLUENCE_PATH")
36
- opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
37
- opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
38
38
  opt_space_key = space_key or os.getenv("CONFLUENCE_SPACE_KEY")
39
39
 
40
40
  if not opt_domain:
41
- raise ConfluenceError("Confluence domain not specified")
41
+ raise ArgumentError("Confluence domain not specified")
42
42
  if not opt_base_path:
43
43
  opt_base_path = "/wiki/"
44
- if not opt_api_key:
45
- raise ConfluenceError("Confluence API key not specified")
46
44
 
47
45
  if opt_domain.startswith(("http://", "https://")) or opt_domain.endswith("/"):
48
- raise ConfluenceError(
46
+ raise ArgumentError(
49
47
  "Confluence domain looks like a URL; only host name required"
50
48
  )
51
49
  if not opt_base_path.startswith("/") or not opt_base_path.endswith("/"):
52
- raise ConfluenceError("Confluence base path must start and end with a '/'")
50
+ raise ArgumentError("Confluence base path must start and end with a '/'")
53
51
 
54
52
  self.domain = opt_domain
55
53
  self.base_path = opt_base_path
54
+ self.space_key = opt_space_key
55
+
56
+
57
+ class ConfluenceConnectionProperties(ConfluenceSiteProperties):
58
+ "Properties related to connecting to Confluence."
59
+
60
+ user_name: Optional[str]
61
+ api_key: str
62
+ headers: Optional[dict[str, str]]
63
+
64
+ def __init__(
65
+ self,
66
+ domain: Optional[str] = None,
67
+ base_path: Optional[str] = None,
68
+ user_name: Optional[str] = None,
69
+ api_key: Optional[str] = None,
70
+ space_key: Optional[str] = None,
71
+ headers: Optional[dict[str, str]] = None,
72
+ ) -> None:
73
+ super().__init__(domain, base_path, space_key)
74
+
75
+ opt_user_name = user_name or os.getenv("CONFLUENCE_USER_NAME")
76
+ opt_api_key = api_key or os.getenv("CONFLUENCE_API_KEY")
77
+
78
+ if not opt_api_key:
79
+ raise ArgumentError("Confluence API key not specified")
80
+
56
81
  self.user_name = opt_user_name
57
82
  self.api_key = opt_api_key
58
- self.space_key = opt_space_key
59
83
  self.headers = headers
@@ -1,20 +0,0 @@
1
- md2conf/__init__.py,sha256=AtPkcrgEezF8jnJ14ALB3VdF6UAWPR9EPSYtoi6y5Nc,402
2
- md2conf/__main__.py,sha256=ypjV_5mE0smlIRBFrpikgzXq18as2hY43HJxMLpzGp4,7145
3
- md2conf/api.py,sha256=uwIR_wBSQqvZ9XZ2m2009Hf8B5w7T5PUXJ88BU8CJmA,19520
4
- md2conf/application.py,sha256=5K-nCPHJZfIahjubrLtXTwI-zsTiD140fdYXDnh3GSk,9161
5
- md2conf/converter.py,sha256=MoGbXqh5rE4qkdxxY8RHcnoZ5mz0aEuFz9nmUnt0WdM,36397
6
- md2conf/emoji.py,sha256=IZeguWqcboeOyJkGLTVONDMO4ZXfYXPgfkp56PTI-hE,1924
7
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
8
- md2conf/matcher.py,sha256=FgMFPvGiOqGezCs8OyerfsVo-iIHFoI6LRMzdcjM5UY,3693
9
- md2conf/mermaid.py,sha256=Alzkv0BY-lju4ojtBdW2qtCLZ59MO9kaS2RpQO6Kyfk,2304
10
- md2conf/processor.py,sha256=G-MIh1jGq9jjgogHnlnRUSrNgiV6_xO6Fy7ct9alqgM,4769
11
- md2conf/properties.py,sha256=WaVVOYSck7drVQfcBJmBMa7Mb0KVOZl9UZHvLS1Du8U,1892
12
- md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
13
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- markdown_to_confluence-0.3.1.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
15
- markdown_to_confluence-0.3.1.dist-info/METADATA,sha256=pTnAvuTg_rgETAUZbsN_5HYbOwXE7qVpDGvhaXMwB2Y,14936
16
- markdown_to_confluence-0.3.1.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
17
- markdown_to_confluence-0.3.1.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
18
- markdown_to_confluence-0.3.1.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
19
- markdown_to_confluence-0.3.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
20
- markdown_to_confluence-0.3.1.dist-info/RECORD,,